Compare commits
69 Commits
feat/plann
...
14cc9fb891
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14cc9fb891 | ||
|
|
7f96ae9508 | ||
|
|
6c54759aa0 | ||
|
|
615c1da665 | ||
|
|
e192285f5d | ||
|
|
a6ca1c0108 | ||
|
|
8f94dddbc5 | ||
|
|
45320427e8 | ||
|
|
16e1ddd129 | ||
|
|
288d2ece8b | ||
|
|
2ad6f20258 | ||
|
|
b2eb5fcfa4 | ||
|
|
8e9f09a8e6 | ||
|
|
ce23f64dc3 | ||
|
|
3008c36921 | ||
|
|
e58cac24e1 | ||
|
|
b9896399fa | ||
|
|
7d87c03cfa | ||
|
|
ef070ddab5 | ||
|
|
3142ba203f | ||
|
|
bc788e1e0f | ||
|
|
a6ebff3f34 | ||
|
|
389d9045d5 | ||
|
|
1aead9dad0 | ||
|
|
9d04d1d9f6 | ||
|
|
4c6fd9f024 | ||
|
|
2cab33d708 | ||
|
|
a1727b647c | ||
|
|
6bdfa73150 | ||
|
|
ada4d9fd9b | ||
|
|
6d460ea996 | ||
|
|
bc0f1e3122 | ||
|
|
63759ee7dc | ||
|
|
62106ff644 | ||
|
|
e77ba35b0e | ||
|
|
8afbf20613 | ||
|
|
5a03dc8430 | ||
|
|
e62485db3b | ||
|
|
a5ebfd12f8 | ||
|
|
2262ab0e13 | ||
|
|
0da527dbbc | ||
|
|
9beda55681 | ||
|
|
6800852ae4 | ||
|
|
48899b3df8 | ||
|
|
fce91bcf86 | ||
|
|
975e1ce50c | ||
|
|
1d61df8160 | ||
|
|
1370bf3dcc | ||
|
|
f2db5f4ad0 | ||
|
|
fd2ac4842f | ||
|
|
4de2deaebe | ||
|
|
b7c60f5838 | ||
| e455d85578 | |||
|
|
0782ba574b | ||
|
|
7b67e35720 | ||
|
|
c048264b95 | ||
|
|
6cb20a9213 | ||
|
|
99c6a71e4c | ||
|
|
0088d6e0e0 | ||
|
|
b115a4c512 | ||
|
|
9e09ae6b4e | ||
|
|
43a3740980 | ||
|
|
d28164caf4 | ||
|
|
77f7cf1423 | ||
|
|
84e6c2d5fc | ||
|
|
84b0ba8670 | ||
|
|
b6bec1e63c | ||
|
|
b32621a4e5 | ||
| 993851009b |
@@ -6,7 +6,10 @@
|
|||||||
"mcp__plugin_context-mode_context-mode__batch_execute",
|
"mcp__plugin_context-mode_context-mode__batch_execute",
|
||||||
"mcp__plugin_context-mode_context-mode__execute",
|
"mcp__plugin_context-mode_context-mode__execute",
|
||||||
"mcp__plugin_context7_context7__query-docs",
|
"mcp__plugin_context7_context7__query-docs",
|
||||||
"mcp__plugin_context-mode_context-mode__search"
|
"mcp__plugin_context-mode_context-mode__search",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"PowerShell(cmdkey *)",
|
||||||
|
"mcp__plugin_context7_context7__resolve-library-id"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
docs/mailbox-proposal.md
Normal file
98
docs/mailbox-proposal.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Task Mailbox — Push Messages Into Running Sessions
|
||||||
|
|
||||||
|
**Status:** proposal
|
||||||
|
**Context:** the user runs parallel Claude sessions (e.g. backend + frontend) and wants to push messages into a session while it's busy inside a subagent. A shared folder works for one-offs; this turns it into a first-class ClaudeDo feature so every future parallel-session project gets it for free.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Claude CLI processes one turn at a time. While a subagent (or any long tool) runs, no new user input can be injected. The harness offers no mid-execution interrupt. The workable window is *between* tool calls — so we need a cheap "inbox check" the agent can poll at natural checkpoints, plus a UI affordance and a cross-session sender.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data
|
||||||
|
|
||||||
|
New table `task_messages`:
|
||||||
|
|
||||||
|
| col | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | INTEGER PK | |
|
||||||
|
| `task_id` | TEXT FK → tasks.id | recipient |
|
||||||
|
| `sender` | TEXT | `'user'` \| `'task:<id>'` (for cross-session) |
|
||||||
|
| `body` | TEXT | markdown |
|
||||||
|
| `created_at` | TEXT | ISO |
|
||||||
|
| `delivered_at` | TEXT NULL | set when inbox pulls it |
|
||||||
|
|
||||||
|
EF Core migration + repository. Async, CancellationToken, matches existing conventions.
|
||||||
|
|
||||||
|
### 2. Worker MCP tools (extend existing `mcp__claudedo__*` server)
|
||||||
|
|
||||||
|
- **`check_inbox(task_id)`** → returns undelivered messages for this task and marks them delivered. Idempotent. Empty array if nothing pending.
|
||||||
|
- **`send_to_task(task_id, body)`** → inserts a row. Callable from *any* session — this is how the frontend session tells the backend session something.
|
||||||
|
- **`inbox_status(task_id)`** → `{ pending: int }` for a cheap "is there anything?" poll.
|
||||||
|
|
||||||
|
All three run in-proc in the Worker, go through the existing repository layer.
|
||||||
|
|
||||||
|
### 3. SignalR additions on `WorkerHub`
|
||||||
|
|
||||||
|
Server methods (UI → Worker):
|
||||||
|
- `SendTaskMessage(taskId, body)` — UI calls this; worker inserts the row and fires `TaskMessageQueued`.
|
||||||
|
|
||||||
|
Client events (Worker → UI):
|
||||||
|
- `TaskMessageQueued(taskId, pendingCount)` — so the UI can show an unread badge.
|
||||||
|
- `TaskMessageDelivered(taskId, pendingCount)` — when the agent pulls it, badge clears.
|
||||||
|
|
||||||
|
### 4. UI
|
||||||
|
|
||||||
|
On every `Running` task row + detail pane:
|
||||||
|
- "Send to session" textarea + Enter to submit → `SendTaskMessage`.
|
||||||
|
- Unread badge showing `pendingCount`.
|
||||||
|
- Read-only message timeline (who sent what, when delivered).
|
||||||
|
|
||||||
|
### 5. Agent-side poll discipline
|
||||||
|
|
||||||
|
Two complementary mechanisms so it's robust whether or not the agent remembers:
|
||||||
|
|
||||||
|
**a) CLAUDE.md instruction** (seeded by worker into each worktree's `CLAUDE.md`):
|
||||||
|
> After every subagent completes and before starting the next step, call `mcp__claudedo__check_inbox`. Treat returned messages as user input with priority over the current plan.
|
||||||
|
|
||||||
|
**b) PostToolUse hook on `Agent`** (written into the worktree's `.claude/settings.json` by the Worker when it creates the tree):
|
||||||
|
- Runs `mcp__claudedo__inbox_status` via a tiny CLI shim the worker ships.
|
||||||
|
- If `pending > 0`, the hook emits a system reminder: "Inbox has N pending messages — call `mcp__claudedo__check_inbox` now."
|
||||||
|
- Keeps the burden off the agent's memory. Belt + suspenders.
|
||||||
|
|
||||||
|
### 6. Cross-session pattern
|
||||||
|
|
||||||
|
Backend session and frontend session are just two tasks with known IDs. Either can call `send_to_task(other_id, body)` via the MCP server. No shared folder needed — the DB is already the shared channel.
|
||||||
|
|
||||||
|
To make this ergonomic:
|
||||||
|
- A "linked tasks" concept: tag two tasks as peers at creation time. The Worker exposes `send_to_peer(body)` as sugar around `send_to_task` so neither session needs to hardcode the other's UUID.
|
||||||
|
|
||||||
|
## Limits (honest)
|
||||||
|
|
||||||
|
- Messages arrive *between* tool calls, not mid-tool. A 20-minute subagent still blocks 20 minutes. Splitting work into shorter subagents is still the right discipline.
|
||||||
|
- If the agent ignores the CLAUDE.md instruction, the hook catches it next tool call — but we can't force immediate consumption.
|
||||||
|
- `-p` (print) mode with stdin prompt is one-shot and can't be extended. This design targets *interactive* sessions (Planning Sessions already use this mode). For queued `-p` runs, the mailbox is effectively a post-run instruction carrier.
|
||||||
|
|
||||||
|
## Why this is the repeatable "Grundgerüst"
|
||||||
|
|
||||||
|
Once this lands in ClaudeDo, the workflow becomes:
|
||||||
|
1. Create two linked tasks (`backend`, `frontend`) with `working_dir` set.
|
||||||
|
2. Start each — each gets its own worktree, its own Planning Session terminal, its own inbox with `check_inbox` + `send_to_peer`.
|
||||||
|
3. Push messages from the UI or from the other session. No per-project scaffolding, no custom hooks, no shared folder.
|
||||||
|
|
||||||
|
Every future parallel-session project inherits the mailbox.
|
||||||
|
|
||||||
|
## Build order (suggested)
|
||||||
|
|
||||||
|
1. Migration + repo + model. Tests first.
|
||||||
|
2. MCP tools (`check_inbox`, `send_to_task`, `inbox_status`) + unit tests.
|
||||||
|
3. SignalR method + events + UI textarea/badge.
|
||||||
|
4. Worker writes CLAUDE.md addendum + `.claude/settings.json` hook into each new worktree.
|
||||||
|
5. Linked-tasks sugar (`send_to_peer`).
|
||||||
|
6. Manual verification: queue a long subagent, send a message, confirm it's picked up at the next tool boundary.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should messages be deleted or soft-kept after delivery? Leaning soft-kept for the timeline UI.
|
||||||
|
- Priority / interrupt semantics — do we want a "high priority" flag that the agent should surface immediately vs. batch?
|
||||||
|
- Should `send_to_peer` also work when the peer is `Queued` (i.e. not yet running)? Probably yes — deliver on start.
|
||||||
198
docs/prompts-inventory.md
Normal file
198
docs/prompts-inventory.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# ClaudeDo — Prompt & CLI Inventory
|
||||||
|
|
||||||
|
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
|
||||||
|
|
||||||
|
Date: 2026-04-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
|
||||||
|
|
||||||
|
Used for every "agent" task that the queue picks up or that `RunNow` dispatches.
|
||||||
|
Orchestration lives in `src/ClaudeDo.Worker/Runner/TaskRunner.cs` and `ClaudeArgsBuilder.cs`.
|
||||||
|
|
||||||
|
### 1.1 User prompt (stdin) — `TaskRunner.RunAsync` ~L101–L110
|
||||||
|
|
||||||
|
Plain text, no template around it:
|
||||||
|
|
||||||
|
```
|
||||||
|
{task.Title}
|
||||||
|
|
||||||
|
{task.Description?.Trim()} ← only if non-empty
|
||||||
|
|
||||||
|
## Sub-Tasks ← only if subtasks exist
|
||||||
|
- [ ] {subtask.Title} ← "[x]" if completed
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- Title is included verbatim — no leading `#` heading.
|
||||||
|
- No role tags, no XML, no delimiters between title and description — just blank lines.
|
||||||
|
- Sub-Tasks section uses markdown checkboxes. This is the only structural scaffolding.
|
||||||
|
- No context about the project, working dir, or git state is added here.
|
||||||
|
|
||||||
|
### 1.2 Retry prompt (on failure, when a session ID exists) — `TaskRunner` ~L126
|
||||||
|
|
||||||
|
```
|
||||||
|
The previous attempt failed with:
|
||||||
|
|
||||||
|
{result.ErrorMarkdown}
|
||||||
|
|
||||||
|
Try again and fix the issues.
|
||||||
|
```
|
||||||
|
|
||||||
|
Fired once per task via `--resume <session_id>`; if the retry also fails, the task is marked Failed.
|
||||||
|
|
||||||
|
### 1.3 Follow-up prompt (multi-turn `ContinueAsync`) — `TaskRunner.ContinueAsync` L159
|
||||||
|
|
||||||
|
The UI/hub supplies `followUpPrompt` as-is; no wrapping. The session is resumed via `--resume`. So the effective "prompt template" is whatever the user types in the Continue textbox.
|
||||||
|
|
||||||
|
### 1.4 System prompt — merged in `TaskRunner` ~L413–L418
|
||||||
|
|
||||||
|
Built by `TaskRunner.MergeInstructions(global, list, task)` which concatenates three optional strings with `\n\n`:
|
||||||
|
|
||||||
|
1. `AppSettings.DefaultClaudeInstructions` (global, set in Settings modal, default `""`)
|
||||||
|
2. `list_config.SystemPrompt` (per-list override)
|
||||||
|
3. `task.SystemPrompt` (per-task override)
|
||||||
|
|
||||||
|
The merged string is passed as `--append-system-prompt <instructions>` to the CLI. Empty/whitespace → flag is omitted entirely.
|
||||||
|
|
||||||
|
**Currently the global `DefaultClaudeInstructions` ships as empty string** (see `AppSettingsEntity.cs` L9). Anything in the system prompt today is whatever the user typed into Settings / List-Settings / Task-Settings.
|
||||||
|
|
||||||
|
### 1.5 CLI args — `ClaudeArgsBuilder.Build` (`ClaudeArgsBuilder.cs`)
|
||||||
|
|
||||||
|
Always on:
|
||||||
|
- `-p`
|
||||||
|
- `--output-format stream-json`
|
||||||
|
- `--verbose`
|
||||||
|
- `--permission-mode {auto|acceptEdits|plan|default}` (legacy `bypassPermissions` → `auto`)
|
||||||
|
|
||||||
|
Conditional:
|
||||||
|
- `--model {sonnet|opus|haiku|...}` — from `task.Model ?? list.Model ?? AppSettings.DefaultModel` (default `sonnet`)
|
||||||
|
- `--max-turns {n}` — `AppSettings.DefaultMaxTurns` (default `100`)
|
||||||
|
- `--append-system-prompt "{merged instructions}"` — see 1.4
|
||||||
|
- `--agents '[{"file":"{path}"}]'` — from task or list override, points at an agent `.md`
|
||||||
|
- `--resume {session_id}` — for retries and `ContinueAsync`
|
||||||
|
|
||||||
|
Unused but pre-declared:
|
||||||
|
- `ResultSchema` — a `{summary, files_changed, commit_type}` JSON schema is serialized but **never attached** to args in `Build`. Dead code today; relevant if we turn on `--output-schema`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Planning-agent prompts (`/plan` / Planning session)
|
||||||
|
|
||||||
|
Used by the Planning feature, which spawns a Claude session inside a git worktree with MCP tools so the agent can create Subtasks under the parent.
|
||||||
|
Source: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`.
|
||||||
|
|
||||||
|
### 2.1 System prompt — `BuildSystemPrompt()` L290–L308
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a planning assistant for ClaudeDo.
|
||||||
|
Your role is to help break down a task into smaller, actionable subtasks.
|
||||||
|
Your final goal WILL ALWAYS be the creation of Subtasks
|
||||||
|
|
||||||
|
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
|
||||||
|
start of every planning session, and follow its process end-to-end. It guides
|
||||||
|
you through clarifying questions, approach exploration, and design approval
|
||||||
|
BEFORE any subtasks are created. Do not create child tasks until the user has
|
||||||
|
approved a design.
|
||||||
|
|
||||||
|
NEVER Change files yourself.
|
||||||
|
|
||||||
|
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
|
||||||
|
design is approved. When you are done planning, finalize the session.
|
||||||
|
|
||||||
|
Be concise and focused. Each subtask should be independently executable.
|
||||||
|
```
|
||||||
|
|
||||||
|
Written to `{session-dir}/system-prompt.md` at session start and fed via `--append-system-prompt`.
|
||||||
|
|
||||||
|
Notes / known oddities
|
||||||
|
- Trailing space on "NEVER Change files yourself. " and on the blank line above the ALWAYS/MCP block.
|
||||||
|
- Mixes voice ("Your role is", "ALWAYS invoke") — could be tightened.
|
||||||
|
- Implicitly relies on the `superpowers:brainstorming` skill being installed in the worktree's Claude config.
|
||||||
|
- Does not name the MCP tools explicitly (the `mcp__claudedo__*` wildcard assumes the agent discovers them via tool listing).
|
||||||
|
|
||||||
|
### 2.2 Initial prompt — `BuildInitialPrompt(task)` L310–L323
|
||||||
|
|
||||||
|
```
|
||||||
|
# Task: {task.Title}
|
||||||
|
|
||||||
|
{task.Description} ← only if non-empty
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Please analyze this task and break it down into concrete subtasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
Written to `{session-dir}/initial-prompt.txt`; the Windows Terminal launcher pipes it to the Claude CLI on start.
|
||||||
|
|
||||||
|
### 2.3 Planning session CLI flags
|
||||||
|
|
||||||
|
`PlanningSessionManager` itself does not build CLI args — the `WindowsTerminalPlanningLauncher` does. Relevant facts:
|
||||||
|
- Permission mode: **plan** (per recent commit `8e9f09a` "run planning agent in plan permission mode and enforce brainstorming skill").
|
||||||
|
- Runs with an `.mcp.json` that points at our local MCP server (`http://127.0.0.1:{port}/mcp`) with a per-session bearer token.
|
||||||
|
- `.claude/settings.local.json` sets `"enableAllProjectMcpServers": true` so the MCP tools auto-activate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Commit-message template (not a prompt, but agent-visible)
|
||||||
|
|
||||||
|
Built by `CommitMessageBuilder.Build` (`CommitMessageBuilder.cs`). Format:
|
||||||
|
|
||||||
|
```
|
||||||
|
{commitType}({listSlug}): {title ≤60 chars}
|
||||||
|
|
||||||
|
{description ≤400 chars} ← only if set
|
||||||
|
|
||||||
|
ClaudeDo-Task: {taskId}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `commitType` comes from `task.CommitType` (default `chore`, list default configurable).
|
||||||
|
- Slug = lowercased list name with non-alphanumerics stripped, runs collapsed to `-`.
|
||||||
|
- The agent sees the resulting commit in `git log` during retries and follow-ups, so phrasing here bleeds into model behavior on multi-turn work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Where each prompt is edited (UI surface)
|
||||||
|
|
||||||
|
| Prompt slot | Edited in | Stored as |
|
||||||
|
|-------------------------------------|--------------------------------------------|--------------------------------------------|
|
||||||
|
| Global `DefaultClaudeInstructions` | Settings modal (`SettingsModalViewModel`) | `app_settings.DefaultClaudeInstructions` |
|
||||||
|
| Per-list system prompt | List-Settings modal | `list_config.SystemPrompt` |
|
||||||
|
| Per-task system prompt | Details island / task agent settings | `tasks.system_prompt` |
|
||||||
|
| Per-task agent file | Details island | `tasks.agent_path` (absolute `.md` path) |
|
||||||
|
| Default model / max turns / perms | Settings modal | `app_settings.*` |
|
||||||
|
| Planning system prompt | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
|
||||||
|
| Planning initial prompt template | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
|
||||||
|
| Retry prompt | **Hard-coded** in `TaskRunner` | not editable |
|
||||||
|
| Task prompt structure (title/desc) | **Hard-coded** in `TaskRunner` | not editable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Things worth reviewing tomorrow
|
||||||
|
|
||||||
|
1. **Task-execution prompt has no frame at all.** Just title + description. Consider whether a thin wrapper (goal / constraints / done-criteria) improves agent focus without bloating small tasks.
|
||||||
|
2. **Global DefaultClaudeInstructions is empty out of the box.** This is the cleanest place to put project-wide guardrails (commit format, branch etiquette, verify-before-done, no force push). Right now nothing is there.
|
||||||
|
3. **Planning system prompt**:
|
||||||
|
- Typo-level: trailing spaces, inconsistent capitalization ("ALWAYS"/"NEVER"/"Always").
|
||||||
|
- "Your final goal WILL ALWAYS be the creation of Subtasks" conflicts slightly with "Do not create child tasks until the user has approved a design" — rewordable.
|
||||||
|
- Does not state how many subtasks is reasonable, nor how granular.
|
||||||
|
- Does not describe the MCP tool surface; the agent has to discover `mcp__claudedo__*` tools.
|
||||||
|
4. **Retry prompt is minimal.** `"Try again and fix the issues."` — could be firmer about not repeating the same failure mode.
|
||||||
|
5. **Sub-Tasks block** is dumped as plain checkboxes with no instruction ("please complete all open items", "do them in order", etc.). If the user relies on subtasks for ordering, that intent isn't conveyed.
|
||||||
|
6. **ResultSchema is defined but unused.** Decide: drop it, or wire it up (`--output-schema`) and start asking for structured summaries.
|
||||||
|
7. **Commit-message template** never tells the agent what `commit_type` to pick when it has flexibility — the value is hard-coded per task. Consider exposing as a prompt hint or inferring from diffs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. File pointers
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — user/retry/follow-up prompts, MergeInstructions
|
||||||
|
- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — CLI args + ResultSchema
|
||||||
|
- `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` — commit template
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — planning system + initial prompts
|
||||||
|
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — planning CLI invocation
|
||||||
|
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — global defaults
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — UI for global defaults
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` — UI for per-list overrides
|
||||||
1918
docs/superpowers/plans/2026-04-24-planning-merge-all.md
Normal file
1918
docs/superpowers/plans/2026-04-24-planning-merge-all.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,799 @@
|
|||||||
|
# Planning UX Polish + Sequential Subtask Queue — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add sequential execution of planning subtasks (new `Waiting` status, context-menu trigger, worker-side chain advancement) plus three small UX changes (auto-collapse done planning parents in the task list, collapsible Description in the Details pane, narrower island GridSplitters).
|
||||||
|
|
||||||
|
**Architecture:** Foundation first — add the new `Waiting` enum value and its surface in the UI (chip, virtual-queued filter, row plumbing). Then ship the three UI polish items independently. Finally build the worker-side chain coordinator behind TDD and wire up the SignalR method + context-menu entry.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core (Sqlite), SignalR, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-24-planning-ux-and-sequential-subtasks-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add `Waiting` status to the enum
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` (TaskStatus enum)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (chip class switch)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (virtual-queued match predicate)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `Waiting` to the enum**
|
||||||
|
|
||||||
|
Append `Waiting` as the last value (keeps existing numeric slots stable for any int-serialized rows).
|
||||||
|
|
||||||
|
`src/ClaudeDo.Data/Models/TaskEntity.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum TaskStatus
|
||||||
|
{
|
||||||
|
Manual,
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Done,
|
||||||
|
Failed,
|
||||||
|
Planning,
|
||||||
|
Planned,
|
||||||
|
Draft,
|
||||||
|
Waiting,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `StatusChipClass` switch in TaskRowViewModel**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — update the switch:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string StatusChipClass => Status switch
|
||||||
|
{
|
||||||
|
TaskStatus.Running => "running",
|
||||||
|
TaskStatus.Failed => "error",
|
||||||
|
TaskStatus.Done => "review",
|
||||||
|
TaskStatus.Queued => "queued",
|
||||||
|
TaskStatus.Waiting => "waiting",
|
||||||
|
_ => "idle",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `IsWaiting` and include it in virtual-queued matching**
|
||||||
|
|
||||||
|
In the same `TaskRowViewModel.cs`, add alongside `IsQueued`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsWaiting => Status == TaskStatus.Waiting;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, find the `TaskMatchesList` static method and update the `virtual:queued` branch so tasks in `Waiting` also match. Locate the existing match for `ListKind.Virtual when list.Id == "virtual:queued"` and change it to match `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`. If the existing line reads `t.Status == TaskStatus.Queued` exactly, replace it with `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both build with 0 errors. Existing warnings OK.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Models/TaskEntity.cs \
|
||||||
|
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||||
|
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
|
||||||
|
git commit -m "feat(data): add Waiting task status and include it in virtual:queued"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Narrower island GridSplitters
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 158 and 170)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Halve the splitter width**
|
||||||
|
|
||||||
|
Both `GridSplitter` elements currently use `Width="5"`. Change both to `Width="3"`. Leave all other attributes untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
|
||||||
|
git commit -m "style(ui): narrow island GridSplitters from 5 to 3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Collapsible Description section in Details pane
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add observable flag + toggle command**
|
||||||
|
|
||||||
|
In `DetailsIslandViewModel.cs`, add beside the existing editable fields:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reset flag when a new task is loaded**
|
||||||
|
|
||||||
|
Find the method that handles a new `Task` being bound (the existing `OnTaskChanged` / `Bind` path — it's the spot that already sets `EditableTitle`, `EditableDescription`, etc.). At the start of the load path where fields get reset, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
IsDescriptionExpanded = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the reset is scattered, put it next to the `EditableDescription = ""` assignment.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wrap the description TextBox in a collapsible section**
|
||||||
|
|
||||||
|
In `DetailsIslandView.axaml`, locate the description TextBox. Wrap it so it looks like:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<Button Classes="flat"
|
||||||
|
Command="{Binding ToggleDescriptionExpandedCommand}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
Padding="0">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<PathIcon Width="10" Height="10"
|
||||||
|
Data="{StaticResource Icon.ChevronDown}"
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||||
|
<PathIcon Width="10" Height="10"
|
||||||
|
Data="{StaticResource Icon.ChevronRight}"
|
||||||
|
IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||||
|
<TextBlock Classes="eyebrow" Text="DESCRIPTION"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- existing description TextBox goes here unchanged, but add: -->
|
||||||
|
<TextBox ...existing attributes...
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing `Icon.ChevronDown` / `Icon.ChevronRight` static resources don't exist, inspect `App.axaml` (or wherever `StaticResource Icon.*` icons live) and pick the closest existing chevron pair. If only one direction exists, use a simple `▾` / `▸` TextBlock substitute:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<TextBlock Text="▾" IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||||
|
<TextBlock Text="▸" IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual verify**
|
||||||
|
|
||||||
|
Launch the app (`dotnet run --project src/ClaudeDo.App`), open a task with a description, click the chevron. Verify the body collapses/expands; verify opening a different task restores the expanded default.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
|
||||||
|
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
|
||||||
|
git commit -m "feat(ui): collapsible description section in details pane"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Auto-collapse done planning parents in task list
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add expansion state + "all children done" flag to `TaskRowViewModel`**
|
||||||
|
|
||||||
|
In `TaskRowViewModel.cs`, add below the existing observable properties:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private bool _areChildrenExpanded = true;
|
||||||
|
[ObservableProperty] private bool _allChildrenDone;
|
||||||
|
|
||||||
|
partial void OnAllChildrenDoneChanged(bool value)
|
||||||
|
{
|
||||||
|
// Default children to collapsed once the planning parent is fully done.
|
||||||
|
if (value) AreChildrenExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleChildrenExpanded() => AreChildrenExpanded = !AreChildrenExpanded;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Compute `AllChildrenDone` during Regroup in `TasksIslandViewModel`**
|
||||||
|
|
||||||
|
In `TasksIslandViewModel.cs`, locate the `Regroup()` method (the one that clears and repopulates `OverdueItems`/`OpenItems`/`CompletedItems`). Before it distributes rows, build a lookup of children by parent id:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var childrenByParent = Items
|
||||||
|
.Where(r => r.IsChild && r.ParentTaskId is not null)
|
||||||
|
.GroupBy(r => r.ParentTaskId!)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
|
||||||
|
{
|
||||||
|
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
|
||||||
|
parent.AllChildrenDone = kids.All(c => c.Status == TaskStatus.Done);
|
||||||
|
else
|
||||||
|
parent.AllChildrenDone = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inside the existing distribution loop, skip child rows whose parent row has `AreChildrenExpanded == false`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var row in Items)
|
||||||
|
{
|
||||||
|
if (row.IsChild && row.ParentTaskId is not null)
|
||||||
|
{
|
||||||
|
var parentRow = Items.FirstOrDefault(p => p.Id == row.ParentTaskId);
|
||||||
|
if (parentRow is not null && !parentRow.AreChildrenExpanded) continue;
|
||||||
|
}
|
||||||
|
// ... existing distribution into Overdue/Open/Completed ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `Regroup()` currently uses LINQ expressions instead of a loop, split them out into explicit foreach so the skip is clear. Keep the overdue/completed logic intact — children of a collapsed parent are excluded from every bucket.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run Regroup when a row's expansion flag toggles**
|
||||||
|
|
||||||
|
In `TasksIslandViewModel.cs`, in the constructor (after `Items` is created), subscribe to changes so toggling one row triggers a regroup:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Items.CollectionChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.NewItems is not null)
|
||||||
|
foreach (TaskRowViewModel r in e.NewItems)
|
||||||
|
r.PropertyChanged += OnItemPropertyChanged;
|
||||||
|
if (e.OldItems is not null)
|
||||||
|
foreach (TaskRowViewModel r in e.OldItems)
|
||||||
|
r.PropertyChanged -= OnItemPropertyChanged;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the handler:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(TaskRowViewModel.AreChildrenExpanded))
|
||||||
|
Regroup();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add chevron toggle button to the planning-parent row**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the main task card where the title/eyebrow row lives (co-located with `PlanningBadge`), add a chevron button visible only when `IsPlanningParent && HasPlanningChildren`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Button Classes="flat"
|
||||||
|
Command="{Binding ToggleChildrenExpandedCommand}"
|
||||||
|
IsVisible="{Binding HasPlanningChildren}"
|
||||||
|
Padding="0" Margin="0,0,6,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock FontSize="10"
|
||||||
|
Text="▾"
|
||||||
|
IsVisible="{Binding AreChildrenExpanded}"/>
|
||||||
|
<TextBlock FontSize="10"
|
||||||
|
Text="▸"
|
||||||
|
IsVisible="{Binding !AreChildrenExpanded}"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Place it immediately before the title TextBlock in the parent-row layout. Leave child rows untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual verify**
|
||||||
|
|
||||||
|
Create a planning parent with ≥2 children. Mark both children `Done` (manually via DB if needed, or via a full planning run). Reload the list — the children should be hidden by default. Click the chevron on the parent — children appear. Click again — collapse.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||||
|
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs \
|
||||||
|
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
|
||||||
|
git commit -m "feat(ui): auto-collapse done planning parents in task list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PlanningChainCoordinator — worker-side chain advancement (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`
|
||||||
|
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the first failing test — queueing sets first child Queued, rest Waiting**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public class PlanningChainCoordinatorTests
|
||||||
|
{
|
||||||
|
private static DbContextOptions<ClaudeDoDbContext> InMemoryOptions() =>
|
||||||
|
new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite("DataSource=:memory:;Cache=Shared")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
private static async Task<(ClaudeDoDbContext ctx, TaskRepository repo)> NewDbAsync()
|
||||||
|
{
|
||||||
|
var ctx = new ClaudeDoDbContext(InMemoryOptions());
|
||||||
|
await ctx.Database.OpenConnectionAsync();
|
||||||
|
await ctx.Database.EnsureCreatedAsync();
|
||||||
|
return (ctx, new TaskRepository(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedPlanningFamily(TaskRepository repo, string parentId, int childCount)
|
||||||
|
{
|
||||||
|
await repo.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = "L1", Title = "Parent",
|
||||||
|
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Planned,
|
||||||
|
});
|
||||||
|
for (int i = 0; i < childCount; i++)
|
||||||
|
{
|
||||||
|
await repo.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = $"{parentId}-c{i}", ListId = "L1", Title = $"Child {i}",
|
||||||
|
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Manual,
|
||||||
|
ParentTaskId = parentId, SortOrder = i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
|
||||||
|
{
|
||||||
|
var (ctx, repo) = await NewDbAsync();
|
||||||
|
await using var _ = ctx;
|
||||||
|
await SeedPlanningFamily(repo, "P", 3);
|
||||||
|
|
||||||
|
var coord = new PlanningChainCoordinator(repo);
|
||||||
|
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||||
|
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test — expect failure (class doesn't exist)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compile error "PlanningChainCoordinator not found".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the coordinator with the minimum to pass**
|
||||||
|
|
||||||
|
`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningChainCoordinator
|
||||||
|
{
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
|
||||||
|
public PlanningChainCoordinator(TaskRepository tasks) => _tasks = tasks;
|
||||||
|
|
||||||
|
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var parent = await _tasks.GetByIdAsync(parentTaskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
||||||
|
|
||||||
|
var children = (await _tasks.GetChildrenAsync(parentTaskId, ct))
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.ToList();
|
||||||
|
if (children.Count == 0)
|
||||||
|
throw new InvalidOperationException("Parent has no subtasks.");
|
||||||
|
|
||||||
|
var bad = children.FirstOrDefault(c => c.Status is not (TaskStatus.Manual or TaskStatus.Planned));
|
||||||
|
if (bad is not null)
|
||||||
|
throw new InvalidOperationException($"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
|
||||||
|
|
||||||
|
for (int i = 0; i < children.Count; i++)
|
||||||
|
{
|
||||||
|
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
|
||||||
|
await _tasks.UpdateAsync(children[i], ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `TaskRepository.GetChildrenAsync` does not yet exist, add it:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// in src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
||||||
|
public Task<List<TaskEntity>> GetChildrenAsync(string parentTaskId, CancellationToken ct = default) =>
|
||||||
|
_ctx.Tasks.Where(t => t.ParentTaskId == parentTaskId).ToListAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the repo uses `AsNoTracking()` elsewhere for reads, match that pattern. For this method we want tracked entities so `UpdateAsync` works without extra attach.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test — expect pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 1 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add failing test — on child Done, next Waiting sibling flips to Queued**
|
||||||
|
|
||||||
|
Append to `PlanningChainCoordinatorTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildDone_FlipsNextWaitingToQueued()
|
||||||
|
{
|
||||||
|
var (ctx, repo) = await NewDbAsync();
|
||||||
|
await using var _ = ctx;
|
||||||
|
await SeedPlanningFamily(repo, "P", 3);
|
||||||
|
|
||||||
|
var coord = new PlanningChainCoordinator(repo);
|
||||||
|
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
// Simulate first child finishing Done.
|
||||||
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
first.Status = TaskStatus.Done;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
|
||||||
|
|
||||||
|
Assert.Equal("P-c1", advanced);
|
||||||
|
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||||
|
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run — expect failure**
|
||||||
|
|
||||||
|
Expected: compile error "OnChildFinishedAsync does not exist".
|
||||||
|
|
||||||
|
- [ ] **Step 7: Implement `OnChildFinishedAsync`**
|
||||||
|
|
||||||
|
In `PlanningChainCoordinator.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Call after a child task transitions to a terminal status.
|
||||||
|
/// Returns the id of the newly-queued sibling (if any), else null.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> OnChildFinishedAsync(string childTaskId, TaskStatus finalStatus, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (finalStatus != TaskStatus.Done) return null;
|
||||||
|
|
||||||
|
var child = await _tasks.GetByIdAsync(childTaskId, ct);
|
||||||
|
if (child?.ParentTaskId is null) return null;
|
||||||
|
|
||||||
|
var siblings = (await _tasks.GetChildrenAsync(child.ParentTaskId, ct))
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var next = siblings
|
||||||
|
.Where(s => s.SortOrder > child.SortOrder && s.Status == TaskStatus.Waiting)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (next is null) return null;
|
||||||
|
|
||||||
|
next.Status = TaskStatus.Queued;
|
||||||
|
await _tasks.UpdateAsync(next, ct);
|
||||||
|
return next.Id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run — expect pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 2 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Add failing test — on Failed, chain stops**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildFailed_DoesNotAdvanceChain()
|
||||||
|
{
|
||||||
|
var (ctx, repo) = await NewDbAsync();
|
||||||
|
await using var _ = ctx;
|
||||||
|
await SeedPlanningFamily(repo, "P", 3);
|
||||||
|
|
||||||
|
var coord = new PlanningChainCoordinator(repo);
|
||||||
|
await coord.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
first.Status = TaskStatus.Failed;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
||||||
|
|
||||||
|
Assert.Null(advanced);
|
||||||
|
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
|
||||||
|
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Run — expect pass (existing guard handles it)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs \
|
||||||
|
src/ClaudeDo.Data/Repositories/TaskRepository.cs \
|
||||||
|
tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
|
||||||
|
git commit -m "feat(worker): add PlanningChainCoordinator with sequential subtask advancement"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Hook chain advancement into TaskRunner finish path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs` (DI registration)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register `PlanningChainCoordinator` in DI**
|
||||||
|
|
||||||
|
Locate `src/ClaudeDo.Worker/Program.cs` where other services are registered (look for `services.AddSingleton<PlanningSessionManager>` or similar). Add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddScoped<PlanningChainCoordinator>();
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `AddScoped` if `TaskRepository` is scoped (check how it's registered — match its lifetime). If `TaskRepository` is constructed ad-hoc inside the worker, add a constructor overload on `PlanningChainCoordinator` that takes `IDbContextFactory<ClaudeDoDbContext>` and builds its own `TaskRepository` per call, then register as Singleton. Mirror the pattern used by `PlanningSessionManager`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inject coordinator into `TaskRunner`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add `PlanningChainCoordinator` to the constructor parameter list and store it in a readonly field (match the style used for `_broadcaster`).
|
||||||
|
|
||||||
|
If `TaskRunner` is not a good fit for direct injection (e.g., it's used in contexts without DI), instead inject `IServiceProvider` / `IDbContextFactory<ClaudeDoDbContext>` and new-up a coordinator inside the finish handler. Pick whichever matches existing `TaskRunner` patterns.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Call coordinator after Done/Failed emission**
|
||||||
|
|
||||||
|
Immediately after each `await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);` on line ~338 and the two failed emissions on lines ~355 and ~372, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
{
|
||||||
|
var advancedId = await _chainCoordinator.OnChildFinishedAsync(
|
||||||
|
task.Id,
|
||||||
|
/* Done or Failed based on path */,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (advancedId is not null)
|
||||||
|
await _broadcaster.TaskUpdated(advancedId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `TaskStatus.Done` in the done-path call site and `TaskStatus.Failed` in the failed-path call sites. For the failed paths that use `justFailed` rather than `task`, read `justFailed?.ParentTaskId` and `justFailed?.Id` to stay consistent with the surrounding code.
|
||||||
|
|
||||||
|
After this call the existing queue-pickup loop will see the newly-Queued sibling and dispatch it on its next tick.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full test suite**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pre-existing tests + 3 new ones pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs \
|
||||||
|
src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "feat(worker): advance planning subtask chain on child finish"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Hub method + client + context menu entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add hub method**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, add (match the style of other planning methods):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task QueuePlanningSubtasks(string parentTaskId)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var coord = new PlanningChainCoordinator(repo);
|
||||||
|
await coord.QueueSubtasksSequentiallyAsync(parentTaskId, CancellationToken.None);
|
||||||
|
|
||||||
|
// Broadcast updates for the parent and all its children so the UI refreshes.
|
||||||
|
var children = await ctx.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
await _broadcaster.TaskUpdated(parentTaskId);
|
||||||
|
foreach (var id in children)
|
||||||
|
await _broadcaster.TaskUpdated(id);
|
||||||
|
|
||||||
|
// Make sure the queue picks up the now-Queued first child immediately.
|
||||||
|
_queueSignal.Wake();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the existing hub constructs `PlanningSessionManager` via DI directly, inject `PlanningChainCoordinator` the same way and call `_chainCoordinator.QueueSubtasksSequentiallyAsync(...)` instead of newing one up. If the hub exposes a queue-wakeup via a different name than `_queueSignal`, use that (search the file for `WakeQueue` or `.Wake()`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add method to `IWorkerClient`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/IWorkerClient.cs`, add next to the other planning methods:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement in `WorkerClient`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add (match the pattern of `StartPlanningSessionAsync` etc.):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) =>
|
||||||
|
_connection.InvokeAsync("QueuePlanningSubtasks", parentTaskId, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `CanQueueSubtasksSequentially` + `HasPlanningChildren` observable to `TaskRowViewModel`**
|
||||||
|
|
||||||
|
Confirm `HasPlanningChildren` exists (it's referenced in the spec). If not, add it as `[ObservableProperty] bool _hasPlanningChildren;` and ensure `TasksIslandViewModel.Regroup()` already sets it (there should be a parent-side "has children" pass similar to the `AllChildrenDone` one added in Task 4 — if not, set it there).
|
||||||
|
|
||||||
|
Then add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool CanQueueSubtasksSequentially =>
|
||||||
|
IsPlanningParent && HasPlanningChildren && !IsChild;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `OnPropertyChanged(nameof(CanQueueSubtasksSequentially))` inside `OnStatusChanged` and `OnHasPlanningChildrenChanged` so the flag refreshes when status or children change.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add context-menu entry**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the existing `<ContextMenu>`, directly after the "Discard planning session" item:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Separator IsVisible="{Binding CanQueueSubtasksSequentially}"/>
|
||||||
|
<MenuItem Header="Queue subtasks sequentially"
|
||||||
|
IsVisible="{Binding CanQueueSubtasksSequentially}"
|
||||||
|
Click="OnQueueSubtasksSequentiallyClick"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add click handler in code-behind**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`, add (match the other `On*Click` handlers — they pull the `TaskRowViewModel` from `DataContext` and call the shell / worker):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async void OnQueueSubtasksSequentiallyClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
var worker = App.Services.GetRequiredService<IWorkerClient>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await worker.QueuePlanningSubtasksAsync(row.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Match the toast/log pattern used by OnSendToQueueClick et al.
|
||||||
|
System.Diagnostics.Debug.WriteLine($"QueuePlanningSubtasks failed: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same `App.Services` / `IWorkerClient` lookup pattern as `OnSendToQueueClick` — do not introduce a new DI pattern. If the existing handlers use a shell/mediator indirection, use that instead.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Manual verify end-to-end**
|
||||||
|
|
||||||
|
1. Launch app: `dotnet run --project src/ClaudeDo.App`.
|
||||||
|
2. Open a planning task with ≥2 subtasks (all in `Manual`/`Planned`).
|
||||||
|
3. Right-click parent → **Queue subtasks sequentially**.
|
||||||
|
4. Confirm in the task list: first child shows `Queued` chip, others show `Waiting` chip.
|
||||||
|
5. Let the first run to completion (or, for a quick smoke test, edit the DB to mark it `Done` and emit `TaskUpdated` via a restart).
|
||||||
|
6. Confirm the next child's status flips `Waiting → Queued` without user interaction.
|
||||||
|
7. Force-fail a child (cancel it mid-run) — confirm remaining `Waiting` children stay `Waiting`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs \
|
||||||
|
src/ClaudeDo.Ui/Services/IWorkerClient.cs \
|
||||||
|
src/ClaudeDo.Ui/Services/WorkerClient.cs \
|
||||||
|
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
|
||||||
|
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml \
|
||||||
|
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
|
||||||
|
git commit -m "feat(ui+worker): context menu to queue planning subtasks sequentially"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review checklist (for the plan author before handing off)
|
||||||
|
|
||||||
|
- All four spec items mapped: auto-collapse (Task 4), collapsible description (Task 3), narrower splitters (Task 2), sequential subtask queue (Tasks 1, 5, 6, 7).
|
||||||
|
- `Waiting` enum touches: enum, chip class, virtual:queued filter — covered in Task 1.
|
||||||
|
- TDD applied where it pays off (the coordinator); UI tasks rely on manual verification (correct for this codebase).
|
||||||
|
- No placeholders. Every code step shows the code to paste.
|
||||||
|
- Type names consistent: `PlanningChainCoordinator`, `QueueSubtasksSequentiallyAsync`, `OnChildFinishedAsync`, `QueuePlanningSubtasksAsync`, `AreChildrenExpanded`, `AllChildrenDone`, `IsDescriptionExpanded` — used the same across tasks.
|
||||||
|
- Commits are small and conventional.
|
||||||
999
docs/superpowers/plans/2026-04-24-planning-worktree-plan.md
Normal file
999
docs/superpowers/plans/2026-04-24-planning-worktree-plan.md
Normal file
@@ -0,0 +1,999 @@
|
|||||||
|
# Planning Session Worktree Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.
|
||||||
|
|
||||||
|
**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath` → `WorktreePath` on resume context
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
|
||||||
|
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
|
||||||
|
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`
|
||||||
|
|
||||||
|
Each file has one clear responsibility; no new files needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend context records with token and worktree info
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit the records**
|
||||||
|
|
||||||
|
Replace the full file content with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionStartContext(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string Token,
|
||||||
|
string WorktreePath,
|
||||||
|
string BranchName,
|
||||||
|
PlanningSessionFiles Files);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionResumeContext(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string ClaudeSessionId,
|
||||||
|
string Token,
|
||||||
|
string WorktreePath);
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to see breakage**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit stub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
|
||||||
|
git commit -m "refactor(worker): extend planning contexts with token and worktree"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit the record**
|
||||||
|
|
||||||
|
Replace the full file content with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionFiles(
|
||||||
|
string SessionDirectory,
|
||||||
|
string SystemPromptPath,
|
||||||
|
string InitialPromptPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
|
||||||
|
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Extend `PlanningSessionManager` constructors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add using directives**
|
||||||
|
|
||||||
|
At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace fields and constructors**
|
||||||
|
|
||||||
|
Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
||||||
|
private readonly TaskRepository? _tasksOverride;
|
||||||
|
private readonly ListRepository? _listsOverride;
|
||||||
|
private readonly AppSettingsRepository? _settingsOverride;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly string _rootDirectory;
|
||||||
|
|
||||||
|
// DI constructor.
|
||||||
|
public PlanningSessionManager(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> factory,
|
||||||
|
GitService git,
|
||||||
|
WorkerConfig cfg,
|
||||||
|
string rootDirectory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_git = git;
|
||||||
|
_cfg = cfg;
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test constructor.
|
||||||
|
public PlanningSessionManager(
|
||||||
|
TaskRepository tasks,
|
||||||
|
ListRepository lists,
|
||||||
|
AppSettingsRepository settings,
|
||||||
|
GitService git,
|
||||||
|
WorkerConfig cfg,
|
||||||
|
string rootDirectory)
|
||||||
|
{
|
||||||
|
_tasksOverride = tasks;
|
||||||
|
_listsOverride = lists;
|
||||||
|
_settingsOverride = settings;
|
||||||
|
_git = git;
|
||||||
|
_cfg = cfg;
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
||||||
|
{
|
||||||
|
if (_tasksOverride is not null)
|
||||||
|
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
|
||||||
|
var ctx = _factory!.CreateDbContext();
|
||||||
|
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**
|
||||||
|
|
||||||
|
Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).
|
||||||
|
|
||||||
|
The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build — expect test breakage**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS (production code compiles).
|
||||||
|
|
||||||
|
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
|
||||||
|
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add a worktree-path helper and the token-file helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string BranchNameFor(string taskId) =>
|
||||||
|
$"claudedo/planning/{taskId.Replace("-", "")}";
|
||||||
|
|
||||||
|
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
|
||||||
|
{
|
||||||
|
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
|
||||||
|
? centralRootOverride!
|
||||||
|
: _cfg.CentralWorktreeRoot;
|
||||||
|
|
||||||
|
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Path.Combine(centralRoot, "planning", taskId)
|
||||||
|
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
|
||||||
|
|
||||||
|
return Path.GetFullPath(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TokenFilePathFor(string sessionDir) =>
|
||||||
|
Path.Combine(sessionDir, "token");
|
||||||
|
|
||||||
|
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(path, token, ct);
|
||||||
|
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
|
||||||
|
// perms from the parent dir apply; acceptable because sessionDir is already
|
||||||
|
// under the user's home (~/.todo-app/sessions/).
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(path);
|
||||||
|
var ac = fi.GetAccessControl();
|
||||||
|
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||||
|
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
|
||||||
|
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
me,
|
||||||
|
System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
fi.SetAccessControl(ac);
|
||||||
|
}
|
||||||
|
catch { /* ACL hardening is best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
throw new InvalidOperationException($"Token file missing: {path}");
|
||||||
|
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "refactor(worker): add worktree path and token file helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `BuildMcpConfigJson` body**
|
||||||
|
|
||||||
|
Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static string BuildMcpConfigJson()
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
mcpServers = new
|
||||||
|
{
|
||||||
|
claudedo = new
|
||||||
|
{
|
||||||
|
type = "http",
|
||||||
|
url = McpServerUrl,
|
||||||
|
headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Also add settings override builder below it**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string SettingsLocalJson = """
|
||||||
|
{
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Rewrite `StartAsync` to create the worktree
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `StartAsync` body (keep signature)**
|
||||||
|
|
||||||
|
Replace the entire method body with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||||
|
if (task.Status != TaskStatus.Manual)
|
||||||
|
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
|
var listWorkingDir = list.WorkingDir
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||||
|
|
||||||
|
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
||||||
|
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
var branchName = BranchNameFor(taskId);
|
||||||
|
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
|
||||||
|
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
|
||||||
|
foreach (var stale in stalePaths)
|
||||||
|
{
|
||||||
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
|
||||||
|
}
|
||||||
|
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
|
||||||
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||||
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write .mcp.json and .claude/settings.local.json into the worktree.
|
||||||
|
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
|
||||||
|
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
|
||||||
|
|
||||||
|
var claudeDir = Path.Combine(worktreePath, ".claude");
|
||||||
|
Directory.CreateDirectory(claudeDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
|
||||||
|
|
||||||
|
// Session dir + token + prompt files.
|
||||||
|
var token = GenerateToken();
|
||||||
|
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||||
|
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
Directory.CreateDirectory(sessionDir);
|
||||||
|
|
||||||
|
var files = new PlanningSessionFiles(
|
||||||
|
sessionDir,
|
||||||
|
Path.Combine(sessionDir, "system-prompt.md"),
|
||||||
|
Path.Combine(sessionDir, "initial-prompt.txt"));
|
||||||
|
|
||||||
|
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
|
||||||
|
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
||||||
|
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
||||||
|
|
||||||
|
return new PlanningSessionStartContext(
|
||||||
|
ParentTaskId: taskId,
|
||||||
|
WorkingDir: worktreePath,
|
||||||
|
Token: token,
|
||||||
|
WorktreePath: worktreePath,
|
||||||
|
BranchName: branchName,
|
||||||
|
Files: files);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `ResumeAsync` body**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.Status != TaskStatus.Planning)
|
||||||
|
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||||
|
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||||
|
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (!Directory.Exists(sessionDir))
|
||||||
|
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
|
var listWorkingDir = list.WorkingDir
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
if (!Directory.Exists(worktreePath))
|
||||||
|
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
||||||
|
|
||||||
|
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
||||||
|
|
||||||
|
return new PlanningSessionResumeContext(
|
||||||
|
ParentTaskId: taskId,
|
||||||
|
WorkingDir: worktreePath,
|
||||||
|
ClaudeSessionId: task.PlanningSessionId,
|
||||||
|
Token: token,
|
||||||
|
WorktreePath: worktreePath);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**
|
||||||
|
|
||||||
|
Replace the existing `FinalizeAsync` body with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
// Best-effort cleanup — don't block finalization on git state.
|
||||||
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (Directory.Exists(sessionDir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**
|
||||||
|
|
||||||
|
Replace the body of `DiscardAsync` with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||||
|
|
||||||
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (Directory.Exists(sessionDir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**
|
||||||
|
|
||||||
|
Add this private method near the other helpers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task TryCleanupWorktreeAsync(
|
||||||
|
string taskId,
|
||||||
|
ListRepository lists,
|
||||||
|
AppSettingsRepository settings,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (tasks, _, _, ctx2) = CreateRepos();
|
||||||
|
await using var __ = ctx2;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct);
|
||||||
|
if (task is null) return;
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct);
|
||||||
|
var listWorkingDir = list?.WorkingDir;
|
||||||
|
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
var branchName = BranchNameFor(taskId);
|
||||||
|
|
||||||
|
if (Directory.Exists(worktreePath))
|
||||||
|
{
|
||||||
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||||
|
}
|
||||||
|
catch { /* best effort — never block finalize/discard */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
|
||||||
|
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update `WindowsTerminalPlanningLauncher`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite `LaunchStartAsync`**
|
||||||
|
|
||||||
|
Replace the full method body with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ctx.WorkingDir))
|
||||||
|
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||||
|
|
||||||
|
if (!File.Exists(ctx.Files.SystemPromptPath))
|
||||||
|
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
|
||||||
|
if (!File.Exists(ctx.Files.InitialPromptPath))
|
||||||
|
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
|
||||||
|
|
||||||
|
var resolvedWt = Resolve(_wtPath);
|
||||||
|
if (resolvedWt is null)
|
||||||
|
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
|
||||||
|
var resolvedClaude = Resolve(_claudePath);
|
||||||
|
if (resolvedClaude is null)
|
||||||
|
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolvedWt,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
||||||
|
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
|
||||||
|
// Arg order: --allowedTools is variadic (space-separated). The positional
|
||||||
|
// prompt must follow a single-value flag, or it will be swallowed.
|
||||||
|
// --append-system-prompt-file serves as that buffer.
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--model");
|
||||||
|
psi.ArgumentList.Add(Model);
|
||||||
|
psi.ArgumentList.Add("--allowedTools");
|
||||||
|
psi.ArgumentList.Add(AllowedTools);
|
||||||
|
psi.ArgumentList.Add("--append-system-prompt-file");
|
||||||
|
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
||||||
|
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
|
||||||
|
|
||||||
|
var proc = Process.Start(psi)
|
||||||
|
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite `LaunchResumeAsync`**
|
||||||
|
|
||||||
|
Replace the full method body with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ctx.WorkingDir))
|
||||||
|
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||||
|
|
||||||
|
var resolvedWt = Resolve(_wtPath);
|
||||||
|
if (resolvedWt is null)
|
||||||
|
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
|
||||||
|
var resolvedClaude = Resolve(_claudePath);
|
||||||
|
if (resolvedClaude is null)
|
||||||
|
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolvedWt,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--resume");
|
||||||
|
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
||||||
|
|
||||||
|
var proc = Process.Start(psi)
|
||||||
|
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
|
||||||
|
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Update DI wiring in `Program.cs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 59–62)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the registration**
|
||||||
|
|
||||||
|
Find:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
new PlanningSessionManager(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
planningSessionsDir));
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
new PlanningSessionManager(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<GitService>(),
|
||||||
|
cfg,
|
||||||
|
planningSessionsDir));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build full worker**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Program.cs
|
||||||
|
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a shared git-init helper**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
|
||||||
|
public static class GitRepoFixture
|
||||||
|
{
|
||||||
|
public static void InitRepoWithInitialCommit(string dir)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
Run(dir, "init", "-b", "main");
|
||||||
|
Run(dir, "config", "user.email", "test@claudedo.local");
|
||||||
|
Run(dir, "config", "user.name", "test");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
|
||||||
|
Run(dir, "add", "-A");
|
||||||
|
Run(dir, "commit", "-m", "chore: seed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(string cwd, params string[] args)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
|
||||||
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||||
|
var p = Process.Start(psi)!;
|
||||||
|
p.WaitForExit();
|
||||||
|
if (p.ExitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**
|
||||||
|
|
||||||
|
In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
_git = new ClaudeDo.Data.Git.GitService();
|
||||||
|
_cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
||||||
|
_settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
|
||||||
|
// Seed settings row so the manager can read strategy.
|
||||||
|
_settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||||
|
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add three private fields to the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly ClaudeDo.Data.Git.GitService _git;
|
||||||
|
private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
|
||||||
|
private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `SeedListAsync` to init a git repo:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||||
|
ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test",
|
||||||
|
WorkingDir = wd,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return (listId, wd);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**
|
||||||
|
|
||||||
|
The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
||||||
|
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
|
||||||
|
Assert.True(File.Exists(mcpPath));
|
||||||
|
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
|
||||||
|
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
||||||
|
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
||||||
|
|
||||||
|
var mcp = await File.ReadAllTextAsync(mcpPath);
|
||||||
|
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
|
||||||
|
Assert.DoesNotContain(ctx.Token, mcp);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**
|
||||||
|
|
||||||
|
Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**
|
||||||
|
|
||||||
|
If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.
|
||||||
|
|
||||||
|
If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run all planning tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
|
||||||
|
Expected: PASS for all tests that previously passed.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
|
||||||
|
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: New tests — worktree creation, cleanup, self-heal, resume
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing "worktree is removed on discard" test**
|
||||||
|
|
||||||
|
Append to the test class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardAsync_RemovesWorktreeAndBranch()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
|
||||||
|
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(ctx.WorktreePath));
|
||||||
|
// branch deleted
|
||||||
|
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
|
||||||
|
Assert.Empty(paths);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect PASS**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add "non-git working dir errors" test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
var t = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run and expect PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add self-heal test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
// Pre-create a colliding branch.
|
||||||
|
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
|
||||||
|
var head = await _git.RevParseHeadAsync(wd);
|
||||||
|
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
|
||||||
|
procInfo.ArgumentList.Add("branch");
|
||||||
|
procInfo.ArgumentList.Add(branch);
|
||||||
|
procInfo.ArgumentList.Add(head);
|
||||||
|
var p = System.Diagnostics.Process.Start(procInfo)!;
|
||||||
|
p.WaitForExit();
|
||||||
|
|
||||||
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run and expect PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add resume test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Simulate the claude session capturing its session id.
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);
|
||||||
|
|
||||||
|
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(startCtx.Token, resumeCtx.Token);
|
||||||
|
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
|
||||||
|
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run all planning tests**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
|
||||||
|
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: Manual end-to-end verification
|
||||||
|
|
||||||
|
**Files:** none (manual)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build all projects**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
|
||||||
|
- No trust prompt appears for the `claudedo` MCP server.
|
||||||
|
- Inside claude, `/mcp` lists `claudedo` as connected.
|
||||||
|
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Click Discard**
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred / follow-up
|
||||||
|
|
||||||
|
- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
|
||||||
|
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
|
||||||
|
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.
|
||||||
@@ -37,7 +37,7 @@ Fields:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DefaultClaudeInstructions` | text | `""` |
|
| `DefaultClaudeInstructions` | text | `""` |
|
||||||
| `DefaultModel` | string | `sonnet` |
|
| `DefaultModel` | string | `sonnet` |
|
||||||
| `DefaultMaxTurns` | int | `30` |
|
| `DefaultMaxTurns` | int | `100` |
|
||||||
| `DefaultPermissionMode` | string | `acceptEdits` |
|
| `DefaultPermissionMode` | string | `acceptEdits` |
|
||||||
| `WorktreeStrategy` | string | `sibling` |
|
| `WorktreeStrategy` | string | `sibling` |
|
||||||
| `CentralWorktreeRoot` | string? | `null` |
|
| `CentralWorktreeRoot` | string? | `null` |
|
||||||
|
|||||||
197
docs/superpowers/specs/2026-04-24-planning-merge-all-design.md
Normal file
197
docs/superpowers/specs/2026-04-24-planning-merge-all-design.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Planning Merge-All & Subtask Visibility — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Status:** Approved design, ready for implementation planning
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Three concrete issues with the current Planning feature:
|
||||||
|
|
||||||
|
1. **Queued subtasks are not visible in the Queue List.** When a planning session finalizes, its subtasks transition to `Queued`, but the Queue List's hierarchy rules only show children when their Planning parent is expanded. A collapsed (or already-`Planned`) parent effectively hides the subtasks.
|
||||||
|
2. **Completed subtasks vanish from view.** Once a subtask becomes `Done`, the regroup logic moves it to the "Completed" bucket. Users expect subtasks to remain visible under their Planning parent until the Planning task itself is marked Done.
|
||||||
|
3. **No aggregated view or bulk merge.** Each subtask must be merged individually through its worktree. There is no way to see a combined diff of all changes produced by a Planning session, and no "merge everything" action.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Treat Planning subtasks as belonging to their Planning parent for visibility and lifecycle purposes.
|
||||||
|
- Provide a single aggregated diff view that shows all changes produced by a Planning session.
|
||||||
|
- Provide a single "Merge all" action that sequentially merges all subtasks, with a usable conflict-resolution flow.
|
||||||
|
- Auto-complete the Planning task when all merges succeed.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Building a full-featured in-app diff editor. Textual unified diff is acceptable for now; conflict *editing* happens in VS Code.
|
||||||
|
- Persisting Merge-all progress across worker restarts. Restart clears in-memory orchestration state; user re-starts Merge-all (already-merged subtasks are skipped because their worktrees are `Merged`).
|
||||||
|
- Modifying how individual subtasks are created, executed, or finalized.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Visibility model
|
||||||
|
|
||||||
|
Planning subtasks are exclusively children of their Planning parent until the Planning task transitions to `Done`. The Planning parent acts as a roll-up in the Queue List.
|
||||||
|
|
||||||
|
- Tasks with a non-null `ParentTaskId` are excluded from all virtual lists (`virtual:queued`, `virtual:running`, `CompletedItems`, etc.) as separate rows.
|
||||||
|
- A Planning/Planned task is included in `virtual:queued` if **any** child is `Queued`, and in `virtual:running` if any child is `Running`.
|
||||||
|
- Children are always attached under their parent in the task tree; expansion purely controls visual collapse.
|
||||||
|
- When Merge-all completes successfully, the Planning task is set to `Done` and the entire subtree moves to Completed together.
|
||||||
|
- Status badge on the Planning row summarizes children (e.g., `3/5 queued`, `2 running`, `1 failed`).
|
||||||
|
|
||||||
|
### 2. Planning detail panel
|
||||||
|
|
||||||
|
Extends the existing task detail view. New elements when the selected task is a Planning task:
|
||||||
|
|
||||||
|
- **Subtasks list.** Grouped by status badge (Queued / Running / Done / Failed). Each row preserves existing per-subtask actions (view logs, open worktree, individual merge).
|
||||||
|
- **Merge target dropdown.** Single target branch that applies to all subtasks in Merge-all. Defaults to the branch that was current when the Planning session started.
|
||||||
|
- **`[Review combined diff]` button.** Opens the Aggregated Diff Viewer. Enabled as soon as any subtask has produced a diff.
|
||||||
|
- **`[Merge all subtasks]` button.** Orchestrates sequential merge + auto-Done. Disabled until every subtask is `Done` and every worktree is `Active` or `Merged` (no `Discarded` / `Kept`). Tooltip explains why when disabled (e.g., "2 subtasks still running", "1 subtask failed — resolve first", "1 worktree was discarded").
|
||||||
|
- Existing per-subtask merge action remains available; Merge-all is additive.
|
||||||
|
|
||||||
|
### 3. Aggregated diff viewer
|
||||||
|
|
||||||
|
New Avalonia view `PlanningDiffView` + `PlanningDiffViewModel`, opened as a modal or dedicated tab.
|
||||||
|
|
||||||
|
**Default — grouped by subtask:**
|
||||||
|
- Left pane: subtask list in creation order with `title • +added −deleted • N files`.
|
||||||
|
- Right pane: selected subtask's diff. Reuse any existing diff-rendering control; if none exists, render unified diff text with basic syntax coloring (monospace, minimal decoration).
|
||||||
|
- Summary stats come from `WorktreeEntity.DiffStat`. Raw diff comes from `git diff <base>..<head>` executed in each subtask's worktree via `GitService`. Cached in memory per subtask until the subtask's HEAD moves.
|
||||||
|
|
||||||
|
**Toggle — "Preview combined diff":**
|
||||||
|
- Calls `PlanningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)`:
|
||||||
|
1. Create/reset branch `planning/<slug>-integration` off the current merge target.
|
||||||
|
2. Merge each subtask's branch sequentially with `--no-ff`.
|
||||||
|
3. On conflict during preview: abort the merge, reset the integration branch, surface a warning identifying which two subtasks conflict. Grouped view remains available.
|
||||||
|
4. On success: compute `git diff <merge-target>..planning/<slug>-integration` and render as a single flat unified diff.
|
||||||
|
- Toggle flips back to grouped mode.
|
||||||
|
|
||||||
|
**Integration-branch lifecycle:** scratch artifact, rebuilt on every preview (deleted + recreated). Cleaned up when the Planning task is marked `Done` or when the Planning session is discarded.
|
||||||
|
|
||||||
|
### 4. Merge-all orchestration
|
||||||
|
|
||||||
|
**Happy path (`PlanningMergeOrchestrator.StartAsync`):**
|
||||||
|
|
||||||
|
1. Pre-flight checks — fail fast with a clear message on any:
|
||||||
|
- Every subtask is `Done`.
|
||||||
|
- Every subtask's worktree is `Active` or `Merged` (no `Discarded` / `Kept`). `Merged` worktrees are allowed so that an interrupted Merge-all can be restarted.
|
||||||
|
- Repo working tree is clean.
|
||||||
|
- No mid-merge in progress in the target repo.
|
||||||
|
2. For each subtask in creation order, skip if its worktree is already `Merged` (idempotent restart). Otherwise call `TaskMergeService.MergeAsync` with `removeWorktree: true` and `leaveConflictsInTree: true`. Each success flips the worktree to `Merged`.
|
||||||
|
3. After the last successful merge:
|
||||||
|
- Set Planning task `Status = Done`.
|
||||||
|
- Call `PlanningAggregator.CleanupIntegrationBranchAsync` if the integration branch exists.
|
||||||
|
- Emit `PlanningCompleted` so the UI removes the row from the Queue List.
|
||||||
|
|
||||||
|
**Conflict path:**
|
||||||
|
|
||||||
|
1. `MergeAsync` with `leaveConflictsInTree: true` reports a conflict, leaves the repo in a mid-merge state, and returns the conflicted file paths (`git diff --name-only --diff-filter=U`).
|
||||||
|
2. Orchestrator halts the loop, stores the in-progress state (remaining subtasks, target branch, current subtask id) in memory, and emits `PlanningMergeConflict(planningTaskId, subtaskId, conflictedFiles)`.
|
||||||
|
3. The UI opens the **Conflict Resolution dialog** — see §5.
|
||||||
|
4. On `ContinueAsync`: calls `TaskMergeService.ContinueMergeAsync(subtaskId)` which stages the recorded files and runs `git commit --no-edit`. Flips worktree to `Merged`. Loop resumes with remaining subtasks.
|
||||||
|
5. On `AbortAsync`: calls `TaskMergeService.AbortMergeAsync(subtaskId)` which runs `git merge --abort`. Planning stays in `Planned`. Already-merged earlier subtasks remain `Merged`. Orchestration state cleared.
|
||||||
|
|
||||||
|
**Idempotent restart:** if the worker restarts mid Merge-all, in-memory state is lost. A fresh `StartAsync` re-runs pre-flight; already-`Merged` worktrees are skipped by the loop (their status gates them out). User experience: "I clicked Merge all again and it continued from where it left off."
|
||||||
|
|
||||||
|
### 5. Conflict Resolution dialog
|
||||||
|
|
||||||
|
Avalonia modal (`ConflictResolutionView` + `ConflictResolutionViewModel`).
|
||||||
|
|
||||||
|
- **Header:** `Conflicts in subtask: <title> merging into <target-branch>`.
|
||||||
|
- **File list:** full absolute paths of conflicted files.
|
||||||
|
- **`[Open all in VS Code]`** — for each file, spawn `code <absolute-path>` via `Process.Start`. If `code` is not on PATH, show an inline error row with the file list so the user can copy paths manually. No popup-on-popup.
|
||||||
|
- **`[I've resolved — continue]`** — calls `ContinuePlanningMerge(planningTaskId)` hub method, closes dialog. The orchestration loop continues with the remaining subtasks.
|
||||||
|
- **`[Abort this merge]`** — calls `AbortPlanningMerge(planningTaskId)` hub method, closes dialog. Planning stays `Planned`.
|
||||||
|
|
||||||
|
### 6. Data model
|
||||||
|
|
||||||
|
**No schema changes.**
|
||||||
|
- Conflicted files are queried from git on demand (`git diff --name-only --diff-filter=U`) while the merge is in progress.
|
||||||
|
- Integration branch name is derived from the Planning task slug: `planning/<slug>-integration`.
|
||||||
|
- Planning completion uses existing `TaskStatus.Done`.
|
||||||
|
|
||||||
|
### 7. Services
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
|
||||||
|
- **`PlanningAggregator`** (`src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`)
|
||||||
|
- `GetAggregatedDiffAsync(planningTaskId, ct)` — returns per-subtask diff entries.
|
||||||
|
- `BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)` — creates/resets the integration branch, merges subtasks sequentially, returns `(success, combinedDiff)` or `(failure, firstConflictSubtaskId, conflictedFiles)`. Always leaves the integration branch in a consistent state (aborts + resets on failure).
|
||||||
|
- `CleanupIntegrationBranchAsync(planningTaskId, ct)` — deletes the integration branch.
|
||||||
|
|
||||||
|
- **`PlanningMergeOrchestrator`** (singleton, `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`)
|
||||||
|
- Owns in-memory state per planning task: `{ remainingSubtasks, targetBranch, currentSubtaskId }`.
|
||||||
|
- `StartAsync(planningTaskId, targetBranch)`, `ContinueAsync(planningTaskId)`, `AbortAsync(planningTaskId)`.
|
||||||
|
- Emits SignalR events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`.
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
|
||||||
|
- **`TaskMergeService`**
|
||||||
|
- `MergeAsync` gets a `leaveConflictsInTree: bool` parameter (default `false`). When `true`, on conflict records conflicted files on the returned result, does **not** call `git merge --abort`.
|
||||||
|
- New `ContinueMergeAsync(taskId, ct)` — stages the recorded conflicted files and runs `git commit --no-edit`, flips worktree to `Merged`.
|
||||||
|
- New `AbortMergeAsync(taskId, ct)` — runs `git merge --abort`, restores pre-merge state.
|
||||||
|
- Existing callers unaffected by the default.
|
||||||
|
|
||||||
|
- **`WorkerHub`** — new methods:
|
||||||
|
- `GetPlanningAggregate(planningTaskId)`
|
||||||
|
- `BuildPlanningIntegrationBranch(planningTaskId, targetBranch)`
|
||||||
|
- `MergeAllPlanning(planningTaskId, targetBranch)`
|
||||||
|
- `ContinuePlanningMerge(planningTaskId)`
|
||||||
|
- `AbortPlanningMerge(planningTaskId)`
|
||||||
|
|
||||||
|
- **`TasksIslandViewModel.Regroup`**
|
||||||
|
- Exclude tasks with `ParentTaskId != null` from virtual lists.
|
||||||
|
- Include Planning parents in `virtual:queued` / `virtual:running` based on children's statuses.
|
||||||
|
- Keep children attached to parent in the tree at all times until Planning is `Done`.
|
||||||
|
|
||||||
|
### 8. UI components (new)
|
||||||
|
|
||||||
|
- `PlanningDiffView` + `PlanningDiffViewModel` — aggregated diff viewer (§3).
|
||||||
|
- `ConflictResolutionView` + `ConflictResolutionViewModel` — conflict dialog (§5).
|
||||||
|
- Planning Detail section inside the existing task detail pane — subtask list + merge target dropdown + two buttons (§2).
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- **Pre-flight failures** — surface as inline errors in the Planning detail panel. No merge work attempted.
|
||||||
|
- **Preview-build conflict** — keep grouped diff available; show a warning banner identifying the conflicting pair of subtasks.
|
||||||
|
- **Merge-all conflict** — Conflict Resolution dialog (§5). The failed subtask's worktree stays `Active`; prior successes stay `Merged`.
|
||||||
|
- **VS Code not on PATH** — inline error row in the Conflict dialog with copyable file paths.
|
||||||
|
- **Worker restart mid-merge** — in-memory state lost; restarting Merge-all is idempotent because merged worktrees are skipped by status gating.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Convention: xUnit integration tests with real SQLite and real git (`tests/ClaudeDo.Worker.Tests`).
|
||||||
|
|
||||||
|
**`PlanningAggregatorTests`** — real git fixture
|
||||||
|
- `GetAggregatedDiffAsync` returns one entry per subtask with correct stats.
|
||||||
|
- `BuildIntegrationBranchAsync` with non-conflicting subtasks — success, branch contains all changes.
|
||||||
|
- `BuildIntegrationBranchAsync` with conflicting subtasks — failure, branch reset (not mid-merge), correct subtask id and file list reported.
|
||||||
|
- Rebuild overwrites a stale integration branch.
|
||||||
|
- `CleanupIntegrationBranchAsync` removes the branch.
|
||||||
|
|
||||||
|
**`PlanningMergeOrchestratorTests`** — real git + real DB
|
||||||
|
- Happy path: all subtasks merge → worktrees `Merged`, Planning `Done`, `PlanningCompleted` emitted.
|
||||||
|
- Conflict path: first subtask conflicts → repo left in conflict state, `PlanningMergeConflict` emitted with correct file list, worktree stays `Active`, Planning stays `Planned`.
|
||||||
|
- `ContinueAsync` after conflict: resolution commits, loop proceeds, final state `Done`.
|
||||||
|
- `AbortAsync` after conflict: `merge --abort` restores clean state, earlier merged subtasks remain `Merged`, Planning stays `Planned`.
|
||||||
|
- Pre-flight rejection: running subtask, failed subtask, dirty repo — each returns the expected error with no side effects.
|
||||||
|
- Idempotent restart: partial merge + fresh `StartAsync` — already-`Merged` worktrees skipped.
|
||||||
|
|
||||||
|
**`TaskMergeServiceConflictTests`** (extending existing tests)
|
||||||
|
- `MergeAsync(leaveConflictsInTree: true)` on conflict: no `merge --abort`, returns conflicted files, worktree state unchanged.
|
||||||
|
- `ContinueMergeAsync`: completes in-progress merge, flips worktree to `Merged`.
|
||||||
|
- `AbortMergeAsync`: runs `merge --abort`, restores clean state.
|
||||||
|
|
||||||
|
**`TasksIslandRegroupTests`** — ViewModel unit tests, no DB
|
||||||
|
- Queued subtask with a Planning parent is NOT in `virtual:queued` as its own row.
|
||||||
|
- Planning parent with any Queued child IS in `virtual:queued`.
|
||||||
|
- Done subtask stays nested under Planning parent until Planning is `Done`.
|
||||||
|
- After Planning is marked `Done`, parent + children move to Completed together.
|
||||||
|
|
||||||
|
**Manual smoke test** (documented in PR description):
|
||||||
|
- End-to-end planning session in the app: create plan, finalize, let subtasks run.
|
||||||
|
- Open aggregated diff, toggle Preview combined.
|
||||||
|
- Merge-all happy path.
|
||||||
|
- Merge-all conflict path with VS Code dialog open/continue.
|
||||||
|
- Merge-all conflict path abort.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None at this stage. All decisions from the brainstorming session are captured above.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Planning UX Polish + Sequential Subtask Queue
|
||||||
|
|
||||||
|
**Status:** design
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Scope:** three small UX changes + one feature — sequential execution of planning subtasks triggered from the context menu.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Collapse the children of a finished planning-parent row in the task list by default.
|
||||||
|
2. Allow the user to collapse the Description section in the Details pane.
|
||||||
|
3. Halve the width of the GridSplitters between islands.
|
||||||
|
4. Let the user queue all subtasks of a planning parent so they run one after another, with a new `Waiting` status for pending siblings.
|
||||||
|
|
||||||
|
## 1. Auto-collapse done planning parents
|
||||||
|
|
||||||
|
**Rule for "done":** a planning parent is "done" when every one of its children has `Status == Done`.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- `TaskRowViewModel`: add UI-only `[ObservableProperty] bool _areChildrenExpanded`. Default computed from status — `false` when the row is a done planning parent, else `true`. Not persisted.
|
||||||
|
- Add `[RelayCommand] void ToggleChildrenExpanded()`.
|
||||||
|
- `TasksIslandView.axaml` (or `TaskRowView.axaml`): chevron button on the planning-parent row, visible only when `IsPlanningParent && HasPlanningChildren`. Bound to the toggle command.
|
||||||
|
- `TasksIslandViewModel.Regroup()`: before adding child rows to `OpenItems`/`CompletedItems`, check each child's parent row in `Items`. If the parent's `AreChildrenExpanded == false`, skip the child.
|
||||||
|
- When a planning parent flips from "not done" → "done" in `OnWorkerTaskUpdated`, call `Regroup()` so the collapse takes effect.
|
||||||
|
|
||||||
|
No DB changes.
|
||||||
|
|
||||||
|
## 2. Collapsible description in Details pane
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- `DetailsIslandViewModel`: `[ObservableProperty] bool _isDescriptionExpanded = true` + `[RelayCommand] void ToggleDescriptionExpanded()`.
|
||||||
|
- `DetailsIslandView.axaml`: wrap the existing description `TextBox` in a `StackPanel`; add a thin header row with the label "Description" and a chevron button. Body's `IsVisible` binds to the flag.
|
||||||
|
- State is per ViewModel instance — reset to `true` whenever a different task is loaded.
|
||||||
|
|
||||||
|
No persistence.
|
||||||
|
|
||||||
|
## 3. Narrower GridSplitters
|
||||||
|
|
||||||
|
`MainWindow.axaml` lines 158 and 170: `Width="5"` → `Width="3"` on both `GridSplitter` elements.
|
||||||
|
|
||||||
|
That's the whole change.
|
||||||
|
|
||||||
|
## 4. Sequential subtask queue
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
- `ClaudeDo.Data/Models/TaskStatus.cs`: add a new enum value `Waiting` (lowercase serialized form `waiting`, matching existing convention).
|
||||||
|
- Verify status is stored as string (it should be based on existing patterns). If stored as int, ensure new value gets a stable numeric slot at the end of the enum to avoid breaking existing rows. **No EF migration** beyond what the enum emits automatically.
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
|
||||||
|
- New SignalR hub method: `QueuePlanningSubtasksAsync(string parentTaskId) : Task`.
|
||||||
|
- Loads all children of the parent, ordered by `SortOrder`.
|
||||||
|
- Validates: parent must be a planning parent, children must currently all be in `Manual` or `Planned` (reject if any child is already Queued/Running/Done/Failed, surface a friendly error).
|
||||||
|
- First child → `Queued`. All other children → `Waiting`. Save.
|
||||||
|
- Emit `TaskUpdated` for each affected task.
|
||||||
|
- Chain progression — hook into the existing finish/complete path that already fires `TaskFinished`:
|
||||||
|
- On a child task finishing with status `Done` **and** its parent has waiting siblings: find the next sibling by `(ParentTaskId == parent.Id && Status == Waiting)` ordered by `SortOrder`, flip to `Queued`, emit `TaskUpdated`, and let the existing queue pickup loop pick it up.
|
||||||
|
- On `Failed`: do nothing. Remaining `Waiting` siblings stay waiting. (A toast for failed tasks will be added in a later spec.)
|
||||||
|
|
||||||
|
This logic lives in a new `PlanningChainCoordinator` service (or similar) in `ClaudeDo.Worker/Planning/`, registered as a singleton and wired into whatever already emits task-finished events.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- `TaskRowView` — add context menu entry **"Queue subtasks sequentially"**:
|
||||||
|
- `IsVisible` bound to `IsPlanningParent && HasPlanningChildren`.
|
||||||
|
- `IsEnabled` when all children are in `Manual` / `Planned` state (new property on `TaskRowViewModel`: `CanQueueSubtasksSequentially`).
|
||||||
|
- Calls `WorkerClient.QueuePlanningSubtasksAsync(Id)`.
|
||||||
|
- `TaskRowViewModel`:
|
||||||
|
- Add `IsWaiting => Status == TaskStatus.Waiting` and extend `StatusChipClass` switch to return a new class `"waiting"`.
|
||||||
|
- Add `CanQueueSubtasksSequentially` (computed; requires access to children).
|
||||||
|
- `StatusColorConverter` — add a muted color for `Waiting` (proposed: the existing `TextMuteBrush` or a faint cyan).
|
||||||
|
- Task list — planning parent continues to appear in virtual:queued because it has a `Queued` child (existing logic). **Extend** the virtual:queued match predicate in `TasksIslandViewModel.TaskMatchesList` so a task matches when `Status == Queued || Status == Waiting`. This ensures all sibling subtasks (the queued one + the waiting ones) render under the parent in that list.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
- `IWorkerClient` / `WorkerClient`: add `QueuePlanningSubtasksAsync(string parentTaskId)` that calls the hub method.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Toast notifications on subtask failure (separate follow-up spec).
|
||||||
|
- Retrying a stopped chain from a failed task (user does it manually via existing actions).
|
||||||
|
- Persisting the collapse state of planning parents or the Description across sessions.
|
||||||
|
- Drag-to-reorder of waiting subtasks (execution order = `SortOrder` at the moment the chain starts).
|
||||||
|
|
||||||
|
## Validation plan
|
||||||
|
|
||||||
|
Manual:
|
||||||
|
- Plan a task with 3 subtasks. Context-menu → Queue subtasks sequentially. Confirm first = Queued, others = Waiting. Watch the first run to Done, confirm the second flips Queued → Running automatically.
|
||||||
|
- Force-fail subtask 2 (cancel or make it fail). Confirm subtask 3 stays Waiting; no further dispatch.
|
||||||
|
- Once all three are Done, confirm the planning parent auto-collapses in the list.
|
||||||
|
- Toggle the Description chevron in the Details pane on an arbitrary task.
|
||||||
|
- Eyeball the narrower GridSplitter — still resizable, still hittable.
|
||||||
|
|
||||||
|
Automated (minimal — only where cheap):
|
||||||
|
- Worker-level unit test for `PlanningChainCoordinator`: happy-path chain advance on Done; no advance on Failed; correct ordering by `SortOrder`.
|
||||||
172
docs/superpowers/specs/2026-04-24-planning-worktree-design.md
Normal file
172
docs/superpowers/specs/2026-04-24-planning-worktree-design.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# Planning Session MCP via Ephemeral Worktree
|
||||||
|
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Status:** Design approved, pending implementation plan
|
||||||
|
**Scope:** `ClaudeDo.Worker` — planning session launch, MCP config delivery
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When a user starts a planning session, `claude` is spawned in the list's working directory via Windows Terminal and passed `--mcp-config <absolute-path>` pointing at a session-local `mcp.json`. In practice, the spawned `claude` session does **not** pick up the ClaudeDo MCP server: `mcp__claudedo__*` tools are not available, and no trust prompt is shown. The user has to fall back to the built-in `TaskCreate` tool, which writes nothing to ClaudeDo.
|
||||||
|
|
||||||
|
The `--mcp-config` flag is documented for headless (`-p`) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — `type: "http"` + `Authorization` header is the documented form).
|
||||||
|
|
||||||
|
The reliable path per Claude Code docs is project-root `.mcp.json` auto-discovery plus a one-time trust approval (or `enableAllProjectMcpServers: true`).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Spawn planning sessions so that `mcp__claudedo__*` tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every `claude` invocation on the machine).
|
||||||
|
- Changing how task execution (non-planning) spawns `claude`.
|
||||||
|
- Supporting planning on a working directory that is not a git repository.
|
||||||
|
|
||||||
|
## Approach: ephemeral planning worktree
|
||||||
|
|
||||||
|
Each planning session runs inside its own short-lived git worktree, created from `HEAD` of the list's working directory. The worktree is the isolated surface where we write `.mcp.json` and the settings override. The worktree is force-removed on `FinalizeAsync` / `DiscardAsync`.
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` (extend to carry worktree path + branch name)
|
||||||
|
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` (may drop `McpConfigPath` if no longer used)
|
||||||
|
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
|
||||||
|
- `src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs` (optional — defensive startup prune)
|
||||||
|
- DI registration in `src/ClaudeDo.Worker/Program.cs` (inject `GitService`, `WorkerConfig`, `IDbContextFactory<ClaudeDoDbContext>` into `PlanningSessionManager`)
|
||||||
|
|
||||||
|
### Data flow on `StartAsync`
|
||||||
|
|
||||||
|
1. Resolve `list.WorkingDir`; hard-error if `null`, not a directory, or not a git repo (`GitService.IsGitRepoAsync`).
|
||||||
|
2. Resolve `HEAD` via `GitService.RevParseHeadAsync`.
|
||||||
|
3. Resolve worktree strategy from `AppSettingsRepository.GetAsync` (same resolution as `WorktreeManager.CreateAsync`):
|
||||||
|
- `sibling` → `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>`
|
||||||
|
- `central` → `<CentralWorktreeRoot>\planning\<taskId>`
|
||||||
|
Normalize with `Path.GetFullPath`.
|
||||||
|
4. Branch name: `claudedo/planning/<taskId-stripped-of-dashes>`.
|
||||||
|
5. `GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct)`. On `"already exists"` failure, run the same self-heal pattern as `WorktreeManager.CreateAsync` (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
|
||||||
|
6. Write into the worktree:
|
||||||
|
- `<worktreePath>\.mcp.json` — JSON with env-var expansion for the token (see below).
|
||||||
|
- `<worktreePath>\.claude\settings.local.json` — `{ "enableAllProjectMcpServers": true }` (create `.claude` dir if missing).
|
||||||
|
7. Write session artifacts in the session directory (unchanged from today): `system-prompt.md`, `initial-prompt.txt`. The session-local `mcp.json` is no longer written — drop that write.
|
||||||
|
8. Return `PlanningSessionStartContext` with `WorkingDir = worktreePath` and a new `WorktreePath` field (redundant with `WorkingDir` for now, but explicit for cleanup). Also carry `BranchName` so finalize/discard can delete it.
|
||||||
|
|
||||||
|
### MCP JSON payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"claudedo": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://127.0.0.1:47821/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The token never lives on disk in literal form — `${CLAUDEDO_PLANNING_TOKEN}` is expanded by Claude Code at load time from the spawned process's environment.
|
||||||
|
|
||||||
|
### `.claude/settings.local.json` payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.
|
||||||
|
|
||||||
|
### Launcher changes (`WindowsTerminalPlanningLauncher`)
|
||||||
|
|
||||||
|
- `LaunchStartAsync`:
|
||||||
|
- Set `psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token` (new field on `PlanningSessionStartContext`).
|
||||||
|
- `-d` now points at the worktree path (already handled by `ctx.WorkingDir` change).
|
||||||
|
- **Remove** `--mcp-config` and its path argument.
|
||||||
|
- Keep `--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill` — `enableAllProjectMcpServers` only handles trust, not per-tool pre-approval.
|
||||||
|
- Keep `--append-system-prompt-file` as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
|
||||||
|
- `LaunchResumeAsync`:
|
||||||
|
- Same env-var setup.
|
||||||
|
- Same `-d <worktreePath>`.
|
||||||
|
- **Remove** `--mcp-config` (the worktree's `.mcp.json` is discovered automatically).
|
||||||
|
- Keep `--resume <ClaudeSessionId>`.
|
||||||
|
|
||||||
|
### Finalize / Discard
|
||||||
|
|
||||||
|
`PlanningSessionManager.FinalizeAsync` and `DiscardAsync` gain:
|
||||||
|
|
||||||
|
1. Look up the worktree path + branch name (deterministic from `taskId` → reuse the same resolution code as `StartAsync`).
|
||||||
|
2. `GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)` — `--force` because claude may have created scratch files.
|
||||||
|
3. `GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct)`.
|
||||||
|
4. Delete the session dir as today.
|
||||||
|
|
||||||
|
All three steps are best-effort in `DiscardAsync` (log warnings, don't throw — the user explicitly asked to discard). `FinalizeAsync` should propagate failures, since a failed cleanup leaves resources we care about.
|
||||||
|
|
||||||
|
### Resume
|
||||||
|
|
||||||
|
Resume already looks up `list.WorkingDir` from the list; the worktree path is deterministic from `taskId`. `ResumeAsync` must:
|
||||||
|
|
||||||
|
1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
|
||||||
|
2. Return `PlanningSessionResumeContext` with `WorkingDir = worktreePath` and the token (re-read from session state — see Token persistence below).
|
||||||
|
|
||||||
|
### Token persistence
|
||||||
|
|
||||||
|
The token today is generated in `StartAsync` and embedded in `mcp.json` at creation time — never read again. With env-var expansion, the token must be available on **resume**. Options:
|
||||||
|
|
||||||
|
- **A) Persist token to session dir** (`<sessionDir>\token`) with `FileOptions.WriteAllBytes`, restrict file ACL to current user. Read on resume.
|
||||||
|
- **B) Store token hash in DB, raw token in memory only** — breaks across Worker restarts → no resume possible.
|
||||||
|
|
||||||
|
**Chosen: A.** Token file sits inside the existing session directory (`<PlanningSessionManager._rootDirectory>\<taskId>\token`), restricted to the current user via Windows ACLs (`File.SetAccessControl` with an explicit DACL granting `FullControl` to `WindowsIdentity.GetCurrent()` only). Cleaned up in `DiscardAsync`/`FinalizeAsync` with the rest of the session dir.
|
||||||
|
|
||||||
|
### Defensive startup cleanup
|
||||||
|
|
||||||
|
`WorktreeMaintenanceService` already prunes worktrees tracked in the DB. Planning worktrees are **not** in the DB (they're purely filesystem-backed, keyed by `taskId` via path convention). Add a lightweight pass:
|
||||||
|
|
||||||
|
- Enumerate directories matching `<root>\.claudedo-worktrees\planning\*` (for each strategy / central root we know about).
|
||||||
|
- For each, check whether a corresponding session dir exists under `~/.todo-app/sessions/<taskId>`.
|
||||||
|
- If no session dir: `git worktree remove --force` + `git branch -D claudedo/planning/<taskId-stripped>`.
|
||||||
|
|
||||||
|
This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `list.WorkingDir` not a git repo | Hard-error on `StartAsync`. Surface message in UI. |
|
||||||
|
| Worktree branch already exists from a prior crashed session | Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as `WorktreeManager.CreateAsync`.) |
|
||||||
|
| User closes Windows Terminal without clicking Finalize/Discard | Session dir + worktree remain. `ResumeAsync` works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes. |
|
||||||
|
| Claude creates/edits files in the planning worktree | Discarded with the worktree. No impact on user's real working dir. |
|
||||||
|
| User deletes the session dir out from under the Worker | `ResumeAsync` hard-errors. Startup cleanup GCs the orphaned worktree. |
|
||||||
|
| Two simultaneous planning sessions on the same task | Already prevented by task status transition (`Planning` is exclusive). No new consideration. |
|
||||||
|
| `HEAD` is on a detached commit | `git worktree add` handles this fine — base commit is explicit. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Extend `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:
|
||||||
|
|
||||||
|
- **Start happy path:** worktree dir exists after `StartAsync`, contains `.mcp.json` with `${CLAUDEDO_PLANNING_TOKEN}` literal, contains `.claude/settings.local.json` with `enableAllProjectMcpServers: true`.
|
||||||
|
- **Finalize cleanup:** worktree dir is gone, branch is gone, session dir is gone.
|
||||||
|
- **Discard cleanup:** same as finalize.
|
||||||
|
- **Self-heal:** pre-create a stale branch `claudedo/planning/<id>`, then `StartAsync` must succeed.
|
||||||
|
- **Non-git working dir:** `StartAsync` throws a specific error type.
|
||||||
|
- **Resume after Worker restart:** seed session dir + token file, recreate `PlanningSessionManager`, `ResumeAsync` returns context pointing at the still-existing worktree.
|
||||||
|
|
||||||
|
Mock `IPlanningTerminalLauncher` (already an interface) so tests don't actually spawn `wt.exe`.
|
||||||
|
|
||||||
|
## Trade-offs and alternatives considered
|
||||||
|
|
||||||
|
1. **Write `.mcp.json` into the user's working dir with backup/restore.** Rejected — clobber risk, file-noise on crash, user's `.gitignore` may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw `${VAR}` string still lives in the user's repo).
|
||||||
|
2. **User-scope registration via installer** (`claude mcp add --scope user`). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every `claude` session on the machine sees claudedo tools.
|
||||||
|
3. **Keep `--mcp-config` and debug why it's not honored.** Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.
|
||||||
|
|
||||||
|
## Open questions resolved
|
||||||
|
|
||||||
|
- **WorkingDir must be a git repo?** Yes — hard-error.
|
||||||
|
- **Worktree path strategy?** Follow the same `sibling`/`central` setting as task execution.
|
||||||
|
- **HEAD snapshot vs WIP?** HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.
|
||||||
|
|
||||||
|
## Implementation sequencing
|
||||||
|
|
||||||
|
A separate implementation plan (via `superpowers:writing-plans`) will break this into test-first steps.
|
||||||
@@ -17,6 +17,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == TaskStatus.Planning ? "planning"
|
: v == TaskStatus.Planning ? "planning"
|
||||||
: v == TaskStatus.Planned ? "planned"
|
: v == TaskStatus.Planned ? "planned"
|
||||||
: v == TaskStatus.Draft ? "draft"
|
: v == TaskStatus.Draft ? "draft"
|
||||||
|
: v == TaskStatus.Waiting ? "waiting"
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static TaskStatus StatusFromString(string v)
|
private static TaskStatus StatusFromString(string v)
|
||||||
@@ -28,6 +29,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
: v == "planning" ? TaskStatus.Planning
|
: v == "planning" ? TaskStatus.Planning
|
||||||
: v == "planned" ? TaskStatus.Planned
|
: v == "planned" ? TaskStatus.Planned
|
||||||
: v == "draft" ? TaskStatus.Draft
|
: v == "draft" ? TaskStatus.Draft
|
||||||
|
: v == "waiting" ? TaskStatus.Waiting
|
||||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||||
|
|
||||||
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
|
||||||
@@ -64,6 +66,8 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
||||||
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
builder.Property(t => t.CreatedBy).HasColumnName("created_by");
|
||||||
|
|
||||||
builder.HasOne(t => t.Parent)
|
builder.HasOne(t => t.Parent)
|
||||||
.WithMany(t => t.Children)
|
.WithMany(t => t.Children)
|
||||||
.HasForeignKey(t => t.ParentTaskId)
|
.HasForeignKey(t => t.ParentTaskId)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskCreatedBy : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "created_by",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "created_by",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
DefaultClaudeInstructions = "",
|
DefaultClaudeInstructions = "",
|
||||||
DefaultMaxTurns = 30,
|
DefaultMaxTurns = 100,
|
||||||
DefaultModel = "sonnet",
|
DefaultModel = "sonnet",
|
||||||
DefaultPermissionMode = "bypassPermissions",
|
DefaultPermissionMode = "bypassPermissions",
|
||||||
WorktreeAutoCleanupDays = 7,
|
WorktreeAutoCleanupDays = 7,
|
||||||
@@ -236,6 +236,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ public sealed class AppSettingsEntity
|
|||||||
|
|
||||||
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
||||||
public string DefaultModel { get; set; } = "sonnet";
|
public string DefaultModel { get; set; } = "sonnet";
|
||||||
public int DefaultMaxTurns { get; set; } = 30;
|
public int DefaultMaxTurns { get; set; } = 100;
|
||||||
public string DefaultPermissionMode { get; set; } = "bypassPermissions";
|
public string DefaultPermissionMode { get; set; } = "auto";
|
||||||
|
|
||||||
public string WorktreeStrategy { get; set; } = "sibling";
|
public string WorktreeStrategy { get; set; } = "sibling";
|
||||||
public string? CentralWorktreeRoot { get; set; }
|
public string? CentralWorktreeRoot { get; set; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public enum TaskStatus
|
|||||||
Planning,
|
Planning,
|
||||||
Planned,
|
Planned,
|
||||||
Draft,
|
Draft,
|
||||||
|
Waiting,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TaskEntity
|
public sealed class TaskEntity
|
||||||
@@ -39,6 +40,8 @@ public sealed class TaskEntity
|
|||||||
public string? PlanningSessionToken { get; set; }
|
public string? PlanningSessionToken { get; set; }
|
||||||
public DateTime? PlanningFinalizedAt { get; set; }
|
public DateTime? PlanningFinalizedAt { get; set; }
|
||||||
|
|
||||||
|
public string? CreatedBy { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
public WorktreeEntity? Worktree { get; set; }
|
public WorktreeEntity? Worktree { get; set; }
|
||||||
|
|||||||
58
src/ClaudeDo.Data/PromptFiles.cs
Normal file
58
src/ClaudeDo.Data/PromptFiles.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
public enum PromptKind { System, Planning, Agent }
|
||||||
|
|
||||||
|
public static class PromptFiles
|
||||||
|
{
|
||||||
|
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
|
||||||
|
|
||||||
|
public static string PathFor(PromptKind kind) => kind switch
|
||||||
|
{
|
||||||
|
PromptKind.System => Path.Combine(Root, "system.md"),
|
||||||
|
PromptKind.Planning => Path.Combine(Root, "planning.md"),
|
||||||
|
PromptKind.Agent => Path.Combine(Root, "agent.md"),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void EnsureExists(PromptKind kind)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Root);
|
||||||
|
var path = PathFor(kind);
|
||||||
|
if (File.Exists(path)) return;
|
||||||
|
File.WriteAllText(path, DefaultFor(kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ReadOrNull(PromptKind kind)
|
||||||
|
{
|
||||||
|
var path = PathFor(kind);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
var content = File.ReadAllText(path).Trim();
|
||||||
|
return string.IsNullOrEmpty(content) ? null : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DefaultFor(PromptKind kind) => kind switch
|
||||||
|
{
|
||||||
|
PromptKind.System =>
|
||||||
|
"# System Prompt\n\n" +
|
||||||
|
"Baseline instructions appended to every task run.\n" +
|
||||||
|
"Edit this file to inject project-wide rules (style, conventions, hard constraints).\n",
|
||||||
|
PromptKind.Planning =>
|
||||||
|
"You are a planning assistant for ClaudeDo.\n" +
|
||||||
|
"Your role is to help break down a task into smaller, actionable subtasks.\n" +
|
||||||
|
"Your final goal WILL ALWAYS be the creation of Subtasks.\n\n" +
|
||||||
|
"ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the\n" +
|
||||||
|
"start of every planning session, and follow its process end-to-end. It guides\n" +
|
||||||
|
"you through clarifying questions, approach exploration, and design approval\n" +
|
||||||
|
"BEFORE any subtasks are created. Do not create child tasks until the user has\n" +
|
||||||
|
"approved a design.\n\n" +
|
||||||
|
"NEVER change files yourself.\n\n" +
|
||||||
|
"ALWAYS use the available MCP tools (mcp__claudedo__*) to create child tasks once\n" +
|
||||||
|
"the design is approved. When you are done planning, finalize the session.\n\n" +
|
||||||
|
"Be concise and focused. Each subtask should be independently executable.\n",
|
||||||
|
PromptKind.Agent =>
|
||||||
|
"# Agent Prompt\n\n" +
|
||||||
|
"Appended to the system prompt for tasks tagged \"agent\" (auto-queued runs).\n" +
|
||||||
|
"Use this for autonomous-execution rules that don't apply to manual runs.\n",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ public sealed class AppSettingsRepository
|
|||||||
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
row.DefaultModel = string.IsNullOrWhiteSpace(updated.DefaultModel) ? "sonnet" : updated.DefaultModel;
|
||||||
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
||||||
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
||||||
? "bypassPermissions" : updated.DefaultPermissionMode;
|
? "auto" : updated.DefaultPermissionMode;
|
||||||
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
||||||
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
||||||
? null : updated.CentralWorktreeRoot;
|
? null : updated.CentralWorktreeRoot;
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ public sealed class TaskRepository
|
|||||||
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
||||||
=> GetByListIdAsync(listId, ct);
|
=> GetByListIdAsync(listId, ct);
|
||||||
|
|
||||||
|
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.CreatedBy == createdBy)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Status transitions
|
#region Status transitions
|
||||||
@@ -267,6 +276,23 @@ public sealed class TaskRepository
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdatePlanningTaskAsync(
|
||||||
|
string taskId,
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
||||||
|
?? throw new InvalidOperationException("Planning task not found.");
|
||||||
|
if (title is not null) entity.Title = title;
|
||||||
|
if (description is not null) entity.Description = description;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Title, entity.Title)
|
||||||
|
.SetProperty(t => t.Description, entity.Description), ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
string sessionToken,
|
string sessionToken,
|
||||||
|
|||||||
2
src/ClaudeDo.Ui/AssemblyInfo.cs
Normal file
2
src/ClaudeDo.Ui/AssemblyInfo.cs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
[assembly: InternalsVisibleTo("ClaudeDo.Ui.Tests")]
|
||||||
19
src/ClaudeDo.Ui/Services/ForegroundHelper.cs
Normal file
19
src/ClaudeDo.Ui/Services/ForegroundHelper.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
internal static class ForegroundHelper
|
||||||
|
{
|
||||||
|
private const int ASFW_ANY = -1;
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool AllowSetForegroundWindow(int dwProcessId);
|
||||||
|
|
||||||
|
// Grants any process the right to take foreground on next SetForegroundWindow call.
|
||||||
|
// Used before RPCs that cause a helper process (e.g. wt.exe) to spawn a new window.
|
||||||
|
public static void AllowAny()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
try { AllowSetForegroundWindow(ASFW_ANY); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,43 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
public interface IWorkerClient
|
public interface IWorkerClient : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
|
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
|
event Action<string>? TaskUpdatedEvent;
|
||||||
|
event Action<string>? WorktreeUpdatedEvent;
|
||||||
|
event Action<string, string>? TaskMessageEvent;
|
||||||
|
|
||||||
|
event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
Task WakeQueueAsync();
|
Task WakeQueueAsync();
|
||||||
|
Task RunNowAsync(string taskId);
|
||||||
|
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||||
|
Task ResetTaskAsync(string taskId);
|
||||||
|
Task CancelTaskAsync(string taskId);
|
||||||
|
Task<List<AgentInfo>> GetAgentsAsync();
|
||||||
|
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||||
|
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
|
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||||
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default);
|
||||||
|
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||||
|
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
|
||||||
|
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||||
|
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
|
||||||
|
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||||
|
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||||
|
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,3 +16,19 @@ public sealed record PlanningSessionResumeInfo(
|
|||||||
string WorkingDir,
|
string WorkingDir,
|
||||||
string ClaudeSessionId,
|
string ClaudeSessionId,
|
||||||
string McpConfigPath);
|
string McpConfigPath);
|
||||||
|
|
||||||
|
public sealed record SubtaskDiffDto(
|
||||||
|
string SubtaskId,
|
||||||
|
string Title,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
string HeadCommit,
|
||||||
|
string? DiffStat,
|
||||||
|
string UnifiedDiff);
|
||||||
|
|
||||||
|
public sealed record CombinedDiffResultDto(
|
||||||
|
bool Success,
|
||||||
|
string? IntegrationBranch,
|
||||||
|
string? UnifiedDiff,
|
||||||
|
string? FirstConflictSubtaskId,
|
||||||
|
IReadOnlyList<string>? ConflictedFiles);
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
|
||||||
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
public event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
public string? LastMergeAllTarget { get; private set; }
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl)
|
||||||
{
|
{
|
||||||
_hub = new HubConnectionBuilder()
|
_hub = new HubConnectionBuilder()
|
||||||
@@ -123,6 +131,31 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
{
|
{
|
||||||
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
|
||||||
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string>("PlanningSubtaskMerged", (planningTaskId, subtaskId) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => PlanningSubtaskMergedEvent?.Invoke(planningTaskId, subtaskId));
|
||||||
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string, IReadOnlyList<string>>("PlanningMergeConflict", (planningTaskId, subtaskId, conflictedFiles) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, conflictedFiles));
|
||||||
|
});
|
||||||
|
|
||||||
|
_hub.On<string>("PlanningMergeAborted", planningTaskId =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => PlanningMergeAbortedEvent?.Invoke(planningTaskId));
|
||||||
|
});
|
||||||
|
|
||||||
|
_hub.On<string>("PlanningCompleted", planningTaskId =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => PlanningCompletedEvent?.Invoke(planningTaskId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
@@ -353,6 +386,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
||||||
|
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||||
|
|
||||||
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
=> await _hub.InvokeAsync("DiscardPlanningSessionAsync", taskId, ct);
|
||||||
|
|
||||||
@@ -362,6 +398,52 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _hub.InvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
|
||||||
|
{
|
||||||
|
LastMergeAllTarget = targetBranch;
|
||||||
|
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AbortPlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
// IWorkerClient explicit implementations (drop typed return values)
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
|
|
||||||
// Current task row (set by IslandsShellViewModel via Bind)
|
// Current task row (set by IslandsShellViewModel via Bind)
|
||||||
@@ -26,9 +26,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
[ObservableProperty] private string _editableTitle = "";
|
[ObservableProperty] private string _editableTitle = "";
|
||||||
|
[ObservableProperty] private string _editableDescription = "";
|
||||||
|
[ObservableProperty] private bool _isEditingDescription;
|
||||||
|
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||||
[ObservableProperty] private string _notes = "";
|
[ObservableProperty] private string _notes = "";
|
||||||
[ObservableProperty] private string _promptInput = "";
|
[ObservableProperty] private string _promptInput = "";
|
||||||
|
|
||||||
|
public bool IsDescriptionEditorVisible => IsDescriptionExpanded && IsEditingDescription;
|
||||||
|
public bool IsDescriptionPreviewVisible => IsDescriptionExpanded && !IsEditingDescription;
|
||||||
|
|
||||||
|
partial void OnIsDescriptionExpandedChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
|
||||||
|
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsEditingDescriptionChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsDescriptionEditorVisible));
|
||||||
|
OnPropertyChanged(nameof(IsDescriptionPreviewVisible));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleEditDescription() => IsEditingDescription = !IsEditingDescription;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||||
|
|
||||||
// Short task-id badge, e.g. "#T1A"
|
// Short task-id badge, e.g. "#T1A"
|
||||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||||
|
|
||||||
@@ -78,6 +102,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
private bool _suppressAgentSave;
|
private bool _suppressAgentSave;
|
||||||
private CancellationTokenSource? _agentSaveCts;
|
private CancellationTokenSource? _agentSaveCts;
|
||||||
|
|
||||||
|
private bool _suppressDescSave;
|
||||||
|
private CancellationTokenSource? _descSaveCts;
|
||||||
|
|
||||||
public bool IsAgentSectionEnabled => !IsRunning;
|
public bool IsAgentSectionEnabled => !IsRunning;
|
||||||
|
|
||||||
[ObservableProperty] private string? _worktreePath;
|
[ObservableProperty] private string? _worktreePath;
|
||||||
@@ -112,6 +139,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||||
|
|
||||||
|
// Planning merge controls
|
||||||
|
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||||
|
[ObservableProperty] private string? _selectedMergeTarget;
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
|
||||||
|
private bool _canMergeAll;
|
||||||
|
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||||||
|
[ObservableProperty] private string? _mergeAllError;
|
||||||
|
|
||||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||||
private readonly StreamLineFormatter _formatter = new();
|
private readonly StreamLineFormatter _formatter = new();
|
||||||
private readonly StringBuilder _claudeBuf = new();
|
private readonly StringBuilder _claudeBuf = new();
|
||||||
@@ -139,10 +175,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
|
// Set by the view so ReviewCombinedDiffCommand can show the planning diff modal
|
||||||
|
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||||||
|
|
||||||
// Set by the view so DeleteTaskCommand can show an error message
|
// Set by the view so DeleteTaskCommand can show an error message
|
||||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||||
|
|
||||||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerClient worker, IServiceProvider services)
|
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -185,6 +224,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
_worker.WorktreeUpdatedEvent += taskId =>
|
_worker.WorktreeUpdatedEvent += taskId =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||||||
|
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
_worker.TaskUpdatedEvent += taskId =>
|
||||||
|
{
|
||||||
|
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
Subtasks.CollectionChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
RecomputeCanMergeAll();
|
||||||
|
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +296,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||||||
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
|
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => QueueAgentSave();
|
||||||
|
|
||||||
|
partial void OnEditableDescriptionChanged(string value)
|
||||||
|
{
|
||||||
|
if (_suppressDescSave || Task is null) return;
|
||||||
|
_descSaveCts?.Cancel();
|
||||||
|
_descSaveCts = new CancellationTokenSource();
|
||||||
|
_ = SaveDescriptionAsync(_descSaveCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task SaveDescriptionAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await System.Threading.Tasks.Task.Delay(400, ct);
|
||||||
|
if (Task is null) return;
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var repo = new TaskRepository(ctx);
|
||||||
|
var entity = await repo.GetByIdAsync(Task.Id);
|
||||||
|
if (entity is null) return;
|
||||||
|
entity.Description = string.IsNullOrWhiteSpace(EditableDescription) ? null : EditableDescription;
|
||||||
|
await repo.UpdateAsync(entity);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private void QueueAgentSave()
|
private void QueueAgentSave()
|
||||||
{
|
{
|
||||||
if (_suppressAgentSave || Task is null) return;
|
if (_suppressAgentSave || Task is null) return;
|
||||||
@@ -313,12 +389,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(TaskIdBadge));
|
OnPropertyChanged(nameof(TaskIdBadge));
|
||||||
Log.Clear();
|
Log.Clear();
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
|
MergeTargetBranches.Clear();
|
||||||
|
SelectedMergeTarget = null;
|
||||||
|
CanMergeAll = false;
|
||||||
|
MergeAllDisabledReason = null;
|
||||||
|
MergeAllError = null;
|
||||||
_claudeBuf.Clear();
|
_claudeBuf.Clear();
|
||||||
|
|
||||||
if (row == null)
|
if (row == null)
|
||||||
{
|
{
|
||||||
_subscribedTaskId = null;
|
_subscribedTaskId = null;
|
||||||
EditableTitle = "";
|
EditableTitle = "";
|
||||||
|
EditableDescription = "";
|
||||||
Notes = "";
|
Notes = "";
|
||||||
Model = null;
|
Model = null;
|
||||||
WorktreePath = null;
|
WorktreePath = null;
|
||||||
@@ -363,6 +445,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
if (entity == null) return;
|
if (entity == null) return;
|
||||||
|
|
||||||
EditableTitle = entity.Title;
|
EditableTitle = entity.Title;
|
||||||
|
_suppressDescSave = true;
|
||||||
|
try { EditableDescription = entity.Description ?? ""; }
|
||||||
|
finally { _suppressDescSave = false; }
|
||||||
Notes = entity.Notes ?? "";
|
Notes = entity.Notes ?? "";
|
||||||
Model = entity.Model;
|
Model = entity.Model;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
@@ -388,6 +473,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
foreach (var s in subs)
|
foreach (var s in subs)
|
||||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||||
|
|
||||||
|
if (entity.Status == ClaudeDo.Data.Models.TaskStatus.Planning ||
|
||||||
|
entity.Status == ClaudeDo.Data.Models.TaskStatus.Planned)
|
||||||
|
{
|
||||||
|
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
@@ -445,6 +536,121 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var children = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Status = child.Status;
|
||||||
|
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MergeTargetBranches.Count == 0)
|
||||||
|
{
|
||||||
|
var childWithWorktree = children.FirstOrDefault(c => c.Worktree != null);
|
||||||
|
if (childWithWorktree != null)
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(childWithWorktree.Id);
|
||||||
|
if (targets != null)
|
||||||
|
{
|
||||||
|
MergeTargetBranches.Clear();
|
||||||
|
foreach (var b in targets.LocalBranches)
|
||||||
|
MergeTargetBranches.Add(b);
|
||||||
|
SelectedMergeTarget = targets.DefaultBranch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RecomputeCanMergeAll();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
||||||
|
{
|
||||||
|
if (Task is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var child = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == childTaskId && t.ParentTaskId == Task.Id);
|
||||||
|
if (child == null) return;
|
||||||
|
|
||||||
|
var existing = Subtasks.FirstOrDefault(s => s.Id == child.Id);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Status = child.Status;
|
||||||
|
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecomputeCanMergeAll();
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void RecomputeCanMergeAll()
|
||||||
|
{
|
||||||
|
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
|
||||||
|
if (notDone > 0)
|
||||||
|
{
|
||||||
|
CanMergeAll = false;
|
||||||
|
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var badWt = Subtasks.FirstOrDefault(c =>
|
||||||
|
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
|
||||||
|
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
|
||||||
|
if (badWt is not null)
|
||||||
|
{
|
||||||
|
CanMergeAll = false;
|
||||||
|
MergeAllDisabledReason = "at least one worktree was discarded/kept";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CanMergeAll = true;
|
||||||
|
MergeAllDisabledReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||||
|
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||||
|
{
|
||||||
|
if (Task is null || ShowPlanningDiffModal is null) return;
|
||||||
|
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, Task.Id, SelectedMergeTarget ?? "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await ShowPlanningDiffModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanReviewDiff() => Task?.IsPlanningParent == true && Subtasks.Any();
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||||
|
private async System.Threading.Tasks.Task MergeAllAsync()
|
||||||
|
{
|
||||||
|
MergeAllError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MergeAllError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -665,4 +871,6 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private bool _done;
|
[ObservableProperty] private bool _done;
|
||||||
|
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
|
||||||
|
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class ListNavItemViewModel : ViewModelBase
|
public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Name { get; init; }
|
|
||||||
public required ListKind Kind { get; init; }
|
public required ListKind Kind { get; init; }
|
||||||
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private int _count;
|
[ObservableProperty] private int _count;
|
||||||
[ObservableProperty] private bool _isActive;
|
[ObservableProperty] private bool _isActive;
|
||||||
[ObservableProperty] private string? _workingDir;
|
[ObservableProperty] private string? _workingDir;
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
|
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || ShowListSettingsModal is null || _services is null) return;
|
if (row is null || ShowListSettingsModal is null || _services is null) return;
|
||||||
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
await vm.LoadAsync(row.Id, row.Name, row.WorkingDir, row.DefaultCommitType);
|
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||||
await ShowListSettingsModal(vm);
|
await ShowListSettingsModal(vm);
|
||||||
await RefreshRowAsync(row.Id);
|
await RefreshRowAsync(row.Id);
|
||||||
}
|
}
|
||||||
@@ -169,6 +170,46 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Select(ListNavItemViewModel item) => SelectedList = item;
|
private void Select(ListNavItemViewModel item) => SelectedList = item;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CreateListAsync()
|
||||||
|
{
|
||||||
|
var entity = new ListEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
|
Name = "New list",
|
||||||
|
DefaultCommitType = "chore",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using (var ctx = await _dbFactory.CreateDbContextAsync())
|
||||||
|
{
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
await lists.AddAsync(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new ListNavItemViewModel
|
||||||
|
{
|
||||||
|
Id = $"user:{entity.Id}",
|
||||||
|
Name = entity.Name,
|
||||||
|
Kind = ListKind.User,
|
||||||
|
IconKey = "Folder",
|
||||||
|
DotColorKey = "Moss",
|
||||||
|
WorkingDir = entity.WorkingDir,
|
||||||
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
|
};
|
||||||
|
Items.Add(item);
|
||||||
|
UserLists.Add(item);
|
||||||
|
SelectedList = item;
|
||||||
|
|
||||||
|
if (ShowListSettingsModal is not null && _services is not null)
|
||||||
|
{
|
||||||
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
|
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
|
||||||
|
await ShowListSettingsModal(vm);
|
||||||
|
await RefreshRowAsync(item.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
||||||
{
|
{
|
||||||
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
|
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
|
||||||
@@ -188,6 +229,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
var entity = await lists.GetByIdAsync(rawId);
|
var entity = await lists.GetByIdAsync(rawId);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
|
|
||||||
|
row.Name = entity.Name;
|
||||||
row.WorkingDir = entity.WorkingDir;
|
row.WorkingDir = entity.WorkingDir;
|
||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
[ObservableProperty] private string? _parentTaskId;
|
[ObservableProperty] private string? _parentTaskId;
|
||||||
[ObservableProperty] private bool _isExpanded = true;
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
|
[ObservableProperty] private bool _hasPlanningChildren;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : $"Created {CreatedAt:MMM d}";
|
||||||
@@ -34,7 +35,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
|
public bool IsPlanningParent => Status == TaskStatus.Planning
|
||||||
|
|| Status == TaskStatus.Planned
|
||||||
|
|| HasPlanningChildren;
|
||||||
public bool IsDraft => Status == TaskStatus.Draft;
|
public bool IsDraft => Status == TaskStatus.Draft;
|
||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
|
||||||
@@ -54,6 +57,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
public bool IsQueued => Status == TaskStatus.Queued;
|
public bool IsQueued => Status == TaskStatus.Queued;
|
||||||
|
public bool IsWaiting => Status == TaskStatus.Waiting;
|
||||||
public bool HasSchedule => ScheduledFor.HasValue;
|
public bool HasSchedule => ScheduledFor.HasValue;
|
||||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
TaskStatus.Failed => "error",
|
TaskStatus.Failed => "error",
|
||||||
TaskStatus.Done => "review",
|
TaskStatus.Done => "review",
|
||||||
TaskStatus.Queued => "queued",
|
TaskStatus.Queued => "queued",
|
||||||
|
TaskStatus.Waiting => "waiting",
|
||||||
_ => "idle",
|
_ => "idle",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(StatusChipClass));
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
|
OnPropertyChanged(nameof(IsWaiting));
|
||||||
OnPropertyChanged(nameof(HasLiveTail));
|
OnPropertyChanged(nameof(HasLiveTail));
|
||||||
OnPropertyChanged(nameof(IsPlanningParent));
|
OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
OnPropertyChanged(nameof(PlanningBadge));
|
OnPropertyChanged(nameof(PlanningBadge));
|
||||||
@@ -89,6 +95,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnHasPlanningChildrenChanged(bool value)
|
||||||
|
=> OnPropertyChanged(nameof(IsPlanningParent));
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
partial void OnLiveTailChanged(string? value) => OnPropertyChanged(nameof(HasLiveTail));
|
||||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||||
@@ -102,24 +111,26 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
|
|
||||||
public static TaskRowViewModel FromEntity(TaskEntity t)
|
public static TaskRowViewModel FromEntity(TaskEntity t)
|
||||||
{
|
{
|
||||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
var row = new TaskRowViewModel { Id = t.Id, CreatedAt = t.CreatedAt };
|
||||||
return new TaskRowViewModel
|
row.UpdateFromEntity(t);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateFromEntity(TaskEntity t)
|
||||||
{
|
{
|
||||||
Id = t.Id,
|
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||||
Title = t.Title,
|
Title = t.Title;
|
||||||
ListName = t.List?.Name ?? "",
|
ListName = t.List?.Name ?? "";
|
||||||
Done = t.Status == TaskStatus.Done,
|
Done = t.Status == TaskStatus.Done;
|
||||||
IsStarred = t.IsStarred,
|
IsStarred = t.IsStarred;
|
||||||
IsMyDay = t.IsMyDay,
|
IsMyDay = t.IsMyDay;
|
||||||
Status = t.Status,
|
Status = t.Status;
|
||||||
Branch = t.Worktree?.BranchName,
|
Branch = t.Worktree?.BranchName;
|
||||||
DiffStat = t.Worktree?.DiffStat,
|
DiffStat = t.Worktree?.DiffStat;
|
||||||
ScheduledFor = t.ScheduledFor,
|
ScheduledFor = t.ScheduledFor;
|
||||||
DiffAdditions = add,
|
DiffAdditions = add;
|
||||||
DiffDeletions = del,
|
DiffDeletions = del;
|
||||||
CreatedAt = t.CreatedAt,
|
ParentTaskId = t.ParentTaskId;
|
||||||
ParentTaskId = t.ParentTaskId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||||
|
|||||||
@@ -49,6 +49,80 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
|
if (_worker is not null)
|
||||||
|
{
|
||||||
|
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
|
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
|
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWorkerTaskMessage(string taskId, string line)
|
||||||
|
{
|
||||||
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
|
if (row is not null) row.LiveTail = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnWorkerTaskUpdated(string taskId)
|
||||||
|
{
|
||||||
|
var list = _currentList;
|
||||||
|
if (list is null) return;
|
||||||
|
|
||||||
|
// virtual:queued / virtual:running include Planning parents whose children match,
|
||||||
|
// which can't be decided from a single entity. Always full-reload in those cases.
|
||||||
|
if (list.Kind == ListKind.Virtual &&
|
||||||
|
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
|
||||||
|
{
|
||||||
|
LoadForList(list);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
|
||||||
|
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
if (existing is not null) Items.Remove(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var matches = TaskMatchesList(entity, list);
|
||||||
|
if (existing is not null && matches) existing.UpdateFromEntity(entity);
|
||||||
|
else if (existing is not null) Items.Remove(existing);
|
||||||
|
else if (matches) { LoadForList(list); return; }
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
|
||||||
|
// parent matches iff any child has the matching status. OnWorkerTaskUpdated handles those
|
||||||
|
// lists via a full reload rather than the delta path.
|
||||||
|
private static bool TaskMatchesList(TaskEntity t, ListNavItemViewModel list) => list.Kind switch
|
||||||
|
{
|
||||||
|
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
||||||
|
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
|
||||||
|
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
|
||||||
|
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
|
||||||
|
ListKind.User => $"user:{t.ListId}" == list.Id,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm)
|
||||||
|
HeaderTitle = vm.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LoadForList(ListNavItemViewModel? list)
|
public void LoadForList(ListNavItemViewModel? list)
|
||||||
@@ -58,7 +132,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
_loadCts = new CancellationTokenSource();
|
_loadCts = new CancellationTokenSource();
|
||||||
var ct = _loadCts.Token;
|
var ct = _loadCts.Token;
|
||||||
|
|
||||||
|
if (_currentList is not null)
|
||||||
|
_currentList.PropertyChanged -= OnCurrentListPropertyChanged;
|
||||||
_currentList = list;
|
_currentList = list;
|
||||||
|
if (_currentList is not null)
|
||||||
|
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
|
||||||
|
|
||||||
Items.Clear();
|
Items.Clear();
|
||||||
OverdueItems.Clear();
|
OverdueItems.Clear();
|
||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
@@ -88,21 +167,46 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
static bool IsPlanningStatus(TaskStatus s) => s == TaskStatus.Planning || s == TaskStatus.Planned;
|
||||||
|
|
||||||
IEnumerable<TaskEntity> filtered = list.Kind switch
|
IEnumerable<TaskEntity> filtered = list.Kind switch
|
||||||
{
|
{
|
||||||
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
||||||
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
||||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||||
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => t.Status == TaskStatus.Queued),
|
ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t =>
|
||||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
(t.Status == TaskStatus.Queued && t.ParentTaskId == null) ||
|
||||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && (c.Status == TaskStatus.Queued || c.Status == TaskStatus.Waiting)))),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t =>
|
||||||
|
(t.Status == TaskStatus.Running && t.ParentTaskId == null) ||
|
||||||
|
(IsPlanningStatus(t.Status) && all.Any(c => c.ParentTaskId == t.Id && c.Status == TaskStatus.Running))),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null),
|
||||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||||
_ => Enumerable.Empty<TaskEntity>(),
|
_ => Enumerable.Empty<TaskEntity>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var t in filtered)
|
var filteredList = filtered.ToList();
|
||||||
|
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
||||||
|
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
||||||
|
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
|
||||||
|
{
|
||||||
|
if (existingIds.Add(c.Id))
|
||||||
|
filteredList.Add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var t in filteredList)
|
||||||
Items.Add(TaskRowViewModel.FromEntity(t));
|
Items.Add(TaskRowViewModel.FromEntity(t));
|
||||||
|
|
||||||
|
// Mark any top-level row that has at least one child as a planning parent,
|
||||||
|
// so its subtasks remain expandable even after the parent is queued/running.
|
||||||
|
var parentsWithChildren = Items
|
||||||
|
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||||
|
.Select(r => r.ParentTaskId!)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var r in Items)
|
||||||
|
if (parentsWithChildren.Contains(r.Id))
|
||||||
|
r.HasPlanningChildren = true;
|
||||||
|
|
||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
}
|
}
|
||||||
@@ -115,6 +219,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
OpenItems.Clear();
|
OpenItems.Clear();
|
||||||
CompletedItems.Clear();
|
CompletedItems.Clear();
|
||||||
|
|
||||||
|
// Auto-collapse planning parents whose every child is Done (unless the user
|
||||||
|
// has explicitly toggled the row — saved state wins).
|
||||||
|
var childrenByParent = Items
|
||||||
|
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||||
|
.GroupBy(r => r.ParentTaskId!)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
|
||||||
|
{
|
||||||
|
if (_expandedState.ContainsKey(parent.Id)) continue;
|
||||||
|
if (childrenByParent.TryGetValue(parent.Id, out var kids)
|
||||||
|
&& kids.Count > 0
|
||||||
|
&& kids.All(c => c.Status == TaskStatus.Done))
|
||||||
|
{
|
||||||
|
parent.IsExpanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore IsExpanded from saved state
|
// Restore IsExpanded from saved state
|
||||||
foreach (var r in Items)
|
foreach (var r in Items)
|
||||||
{
|
{
|
||||||
@@ -129,7 +250,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
foreach (var parent in topLevel)
|
foreach (var parent in topLevel)
|
||||||
{
|
{
|
||||||
flat.Add(parent);
|
flat.Add(parent);
|
||||||
if (parent.IsPlanningParent && parent.IsExpanded)
|
// Also expand for Done parents so their (Done) children reach the classification
|
||||||
|
// loop and land in CompletedItems alongside the parent.
|
||||||
|
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
||||||
{
|
{
|
||||||
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
||||||
flat.AddRange(children);
|
flat.AddRange(children);
|
||||||
@@ -139,7 +262,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
foreach (var r in flat)
|
foreach (var r in flat)
|
||||||
{
|
{
|
||||||
if (r.Done)
|
var underOpenPlanningParent = r.IsChild &&
|
||||||
|
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
|
||||||
|
|
||||||
|
if (r.Done && !underOpenPlanningParent)
|
||||||
CompletedItems.Add(r);
|
CompletedItems.Add(r);
|
||||||
else if (r.ScheduledFor is { } d && d.Date < today)
|
else if (r.ScheduledFor is { } d && d.Date < today)
|
||||||
OverdueItems.Add(r);
|
OverdueItems.Add(r);
|
||||||
@@ -385,10 +511,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || row.Status != TaskStatus.Manual) return;
|
if (row is null || row.Status != TaskStatus.Manual) return;
|
||||||
|
ForegroundHelper.AllowAny();
|
||||||
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RunInteractivelyAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || _worker is null) return;
|
||||||
|
ForegroundHelper.AllowAny();
|
||||||
|
try { await _worker.OpenInteractiveTerminalAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
private async Task ResumePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
@@ -412,6 +548,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
switch (choice)
|
switch (choice)
|
||||||
{
|
{
|
||||||
case UnfinishedPlanningModalResult.Resume:
|
case UnfinishedPlanningModalResult.Resume:
|
||||||
|
ForegroundHelper.AllowAny();
|
||||||
await _worker.ResumePlanningSessionAsync(row.Id);
|
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||||
@@ -436,6 +573,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null || _worker is null) return;
|
||||||
|
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
@@ -27,6 +30,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
|
|
||||||
private readonly UpdateCheckService _updateCheck;
|
private readonly UpdateCheckService _updateCheck;
|
||||||
private readonly InstallerLocator _installerLocator;
|
private readonly InstallerLocator _installerLocator;
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
|
||||||
|
|
||||||
|
// Set by MainWindow to open the conflict resolution dialog.
|
||||||
|
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||||
|
|
||||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||||
@@ -84,6 +91,47 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
WorkerLogText = null;
|
WorkerLogText = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||||
|
{
|
||||||
|
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||||
|
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||||
|
{
|
||||||
|
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||||
|
|
||||||
|
string subtaskTitle = subtaskId;
|
||||||
|
string worktreePath = System.Environment.CurrentDirectory;
|
||||||
|
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await ctx.Tasks
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||||
|
if (entity != null)
|
||||||
|
{
|
||||||
|
subtaskTitle = entity.Title;
|
||||||
|
if (entity.Worktree?.Path is { } p)
|
||||||
|
worktreePath = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||||
|
|
||||||
|
var vm = new ConflictResolutionViewModel(
|
||||||
|
Worker!,
|
||||||
|
planningTaskId,
|
||||||
|
subtaskTitle,
|
||||||
|
targetBranch,
|
||||||
|
conflictedFiles,
|
||||||
|
worktreePath);
|
||||||
|
|
||||||
|
await ShowConflictDialog(vm);
|
||||||
|
}
|
||||||
|
|
||||||
// For tests only — does NOT wire up events.
|
// For tests only — does NOT wire up events.
|
||||||
internal IslandsShellViewModel() { }
|
internal IslandsShellViewModel() { }
|
||||||
|
|
||||||
@@ -93,11 +141,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
DetailsIslandViewModel details,
|
DetailsIslandViewModel details,
|
||||||
WorkerClient worker,
|
WorkerClient worker,
|
||||||
UpdateCheckService updateCheck,
|
UpdateCheckService updateCheck,
|
||||||
InstallerLocator installerLocator)
|
InstallerLocator installerLocator,
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||||
_updateCheck = updateCheck;
|
_updateCheck = updateCheck;
|
||||||
_installerLocator = installerLocator;
|
_installerLocator = installerLocator;
|
||||||
|
_dbFactory = dbFactory;
|
||||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||||
@@ -122,6 +172,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||||
|
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
|
||||||
_clearTimer.Elapsed += (_, _) =>
|
_clearTimer.Elapsed += (_, _) =>
|
||||||
{
|
{
|
||||||
if (Dispatcher.UIThread.CheckAccess())
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
[ObservableProperty] private string _defaultClaudeInstructions = "";
|
||||||
[ObservableProperty] private string _defaultModel = "sonnet";
|
[ObservableProperty] private string _defaultModel = "sonnet";
|
||||||
[ObservableProperty] private int _defaultMaxTurns = 30;
|
[ObservableProperty] private int _defaultMaxTurns = 100;
|
||||||
[ObservableProperty] private string _defaultPermissionMode = "bypassPermissions";
|
[ObservableProperty] private string _defaultPermissionMode = "auto";
|
||||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||||
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
|
[ObservableProperty] private bool _worktreeAutoCleanupEnabled;
|
||||||
@@ -28,7 +28,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
public IReadOnlyList<string> Models { get; } = new[] { "opus", "sonnet", "haiku" };
|
||||||
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
public IReadOnlyList<string> PermissionModes { get; } = new[]
|
||||||
{ "bypassPermissions", "acceptEdits", "plan", "default" };
|
{ "auto", "bypassPermissions", "acceptEdits", "plan", "default" };
|
||||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||||
|
|
||||||
public string AppVersion { get; } =
|
public string AppVersion { get; } =
|
||||||
@@ -38,6 +38,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||||
|
|
||||||
|
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
|
||||||
|
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
|
||||||
|
public string AgentPromptPath { get; } = PromptFiles.PathFor(PromptKind.Agent);
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
public SettingsModalViewModel(WorkerClient worker)
|
public SettingsModalViewModel(WorkerClient worker)
|
||||||
@@ -56,7 +60,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
DefaultClaudeInstructions = dto.DefaultClaudeInstructions ?? "";
|
||||||
DefaultModel = dto.DefaultModel ?? "sonnet";
|
DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||||
DefaultMaxTurns = dto.DefaultMaxTurns;
|
DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? "bypassPermissions";
|
DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
|
||||||
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||||
CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||||
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||||
@@ -103,7 +107,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
DefaultClaudeInstructions ?? "",
|
DefaultClaudeInstructions ?? "",
|
||||||
DefaultModel ?? "sonnet",
|
DefaultModel ?? "sonnet",
|
||||||
DefaultMaxTurns,
|
DefaultMaxTurns,
|
||||||
DefaultPermissionMode ?? "bypassPermissions",
|
DefaultPermissionMode ?? "auto",
|
||||||
WorktreeStrategy ?? "sibling",
|
WorktreeStrategy ?? "sibling",
|
||||||
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
|
string.IsNullOrWhiteSpace(CentralWorktreeRoot) ? null : CentralWorktreeRoot,
|
||||||
WorktreeAutoCleanupEnabled,
|
WorktreeAutoCleanupEnabled,
|
||||||
@@ -199,4 +203,20 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenPrompt(string? kindName)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<PromptKind>(kindName, ignoreCase: true, out var kind)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PromptFiles.EnsureExists(kind);
|
||||||
|
var path = PromptFiles.PathFor(kind);
|
||||||
|
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Open failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _planningTaskId;
|
||||||
|
private readonly string _worktreePath;
|
||||||
|
|
||||||
|
public string SubtaskTitle { get; }
|
||||||
|
public string TargetBranch { get; }
|
||||||
|
public IReadOnlyList<string> ConflictedFiles { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _vsCodeError;
|
||||||
|
[ObservableProperty] private string? _actionError;
|
||||||
|
|
||||||
|
public Action? CloseRequested { get; set; }
|
||||||
|
|
||||||
|
public ConflictResolutionViewModel(
|
||||||
|
IWorkerClient worker,
|
||||||
|
string planningTaskId,
|
||||||
|
string subtaskTitle,
|
||||||
|
string targetBranch,
|
||||||
|
IReadOnlyList<string> conflictedFiles,
|
||||||
|
string worktreePath)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_planningTaskId = planningTaskId;
|
||||||
|
_worktreePath = worktreePath;
|
||||||
|
SubtaskTitle = subtaskTitle;
|
||||||
|
TargetBranch = targetBranch;
|
||||||
|
ConflictedFiles = conflictedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenInVsCode()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "code",
|
||||||
|
Arguments = args,
|
||||||
|
WorkingDirectory = _worktreePath,
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
VsCodeError = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
VsCodeError = $"Could not launch VS Code: {ex.Message}. Paths are listed above — copy them manually.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ContinueAsync()
|
||||||
|
{
|
||||||
|
ActionError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.ContinuePlanningMergeAsync(_planningTaskId);
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ActionError = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AbortAsync()
|
||||||
|
{
|
||||||
|
ActionError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.AbortPlanningMergeAsync(_planningTaskId);
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ActionError = ex.Message; }
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs
Normal file
91
src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
public sealed partial class PlanningDiffViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _planningTaskId;
|
||||||
|
private readonly string _targetBranch;
|
||||||
|
|
||||||
|
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
|
||||||
|
[ObservableProperty] private string _displayedDiff = "";
|
||||||
|
[ObservableProperty] private bool _isCombinedMode;
|
||||||
|
[ObservableProperty] private string? _combinedWarning;
|
||||||
|
[ObservableProperty] private bool _isLoadingCombined;
|
||||||
|
|
||||||
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_planningTaskId = planningTaskId;
|
||||||
|
_targetBranch = targetBranch;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close() => CloseAction?.Invoke();
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
var items = await _worker.GetPlanningAggregateAsync(_planningTaskId);
|
||||||
|
Subtasks.Clear();
|
||||||
|
foreach (var i in items)
|
||||||
|
Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff));
|
||||||
|
SelectedSubtask = Subtasks.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value)
|
||||||
|
{
|
||||||
|
if (!IsCombinedMode)
|
||||||
|
DisplayedDiff = value?.UnifiedDiff ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleCombinedAsync()
|
||||||
|
{
|
||||||
|
if (IsCombinedMode)
|
||||||
|
{
|
||||||
|
IsLoadingCombined = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId, _targetBranch);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
DisplayedDiff = "";
|
||||||
|
CombinedWarning = "Could not build combined preview (hub error).";
|
||||||
|
}
|
||||||
|
else if (result.Success)
|
||||||
|
{
|
||||||
|
DisplayedDiff = result.UnifiedDiff ?? "";
|
||||||
|
CombinedWarning = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var files = result.ConflictedFiles?.Count ?? 0;
|
||||||
|
CombinedWarning = $"Cannot build combined preview: subtask {result.FirstConflictSubtaskId} conflicts with an earlier subtask ({files} files).";
|
||||||
|
DisplayedDiff = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoadingCombined = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
|
||||||
|
CombinedWarning = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
|
||||||
5
src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml
Normal file
5
src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Controls.MarkdownView">
|
||||||
|
<StackPanel x:Name="Host" Spacing="6"/>
|
||||||
|
</UserControl>
|
||||||
231
src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs
Normal file
231
src/ClaudeDo.Ui/Views/Controls/MarkdownView.axaml.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Controls;
|
||||||
|
|
||||||
|
public partial class MarkdownView : UserControl
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<string?> MarkdownProperty =
|
||||||
|
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
|
||||||
|
|
||||||
|
public string? Markdown
|
||||||
|
{
|
||||||
|
get => GetValue(MarkdownProperty);
|
||||||
|
set => SetValue(MarkdownProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StackPanel? _host;
|
||||||
|
|
||||||
|
public MarkdownView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_host = this.FindControl<StackPanel>("Host");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
if (change.Property == MarkdownProperty)
|
||||||
|
Render(change.GetNewValue<string?>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private void Render(string? md)
|
||||||
|
{
|
||||||
|
_host ??= this.FindControl<StackPanel>("Host");
|
||||||
|
if (_host is null) return;
|
||||||
|
_host.Children.Clear();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(md))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var lines = md.Replace("\r\n", "\n").Split('\n');
|
||||||
|
int i = 0;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) { i++; continue; }
|
||||||
|
|
||||||
|
if (line.StartsWith("### "))
|
||||||
|
{
|
||||||
|
_host.Children.Add(Heading(line[4..], 13));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("## "))
|
||||||
|
{
|
||||||
|
_host.Children.Add(Heading(line[3..], 15));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("# "))
|
||||||
|
{
|
||||||
|
_host.Children.Add(Heading(line[2..], 17));
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith("- ") || line.StartsWith("* "))
|
||||||
|
{
|
||||||
|
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
|
||||||
|
{
|
||||||
|
_host.Children.Add(Bullet(lines[i][2..]));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var num = NumberedItem.Match(line);
|
||||||
|
if (num.Success)
|
||||||
|
{
|
||||||
|
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
|
||||||
|
{
|
||||||
|
var m = NumberedItem.Match(lines[i]);
|
||||||
|
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraph = new System.Text.StringBuilder();
|
||||||
|
while (i < lines.Length
|
||||||
|
&& !string.IsNullOrWhiteSpace(lines[i])
|
||||||
|
&& !IsBlockStart(lines[i]))
|
||||||
|
{
|
||||||
|
if (paragraph.Length > 0) paragraph.Append(' ');
|
||||||
|
paragraph.Append(lines[i].Trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
_host.Children.Add(Paragraph(paragraph.ToString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBlockStart(string line) =>
|
||||||
|
line.StartsWith("# ") || line.StartsWith("## ") || line.StartsWith("### ")
|
||||||
|
|| line.StartsWith("- ") || line.StartsWith("* ")
|
||||||
|
|| NumberedItem.IsMatch(line);
|
||||||
|
|
||||||
|
private static Control Heading(string text, double size)
|
||||||
|
{
|
||||||
|
var tb = new SelectableTextBlock
|
||||||
|
{
|
||||||
|
FontSize = size,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
TextWrapping = TextWrapping.Wrap,
|
||||||
|
Margin = new Thickness(0, 4, 0, 2),
|
||||||
|
};
|
||||||
|
AppendInlines(tb.Inlines!, text);
|
||||||
|
return tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control Paragraph(string text)
|
||||||
|
{
|
||||||
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||||
|
AppendInlines(tb.Inlines!, text);
|
||||||
|
return tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control Bullet(string text)
|
||||||
|
{
|
||||||
|
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("14,*") };
|
||||||
|
var dot = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "•",
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
|
};
|
||||||
|
Grid.SetColumn(dot, 0);
|
||||||
|
grid.Children.Add(dot);
|
||||||
|
|
||||||
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||||
|
AppendInlines(tb.Inlines!, text);
|
||||||
|
Grid.SetColumn(tb, 1);
|
||||||
|
grid.Children.Add(tb);
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Control Numbered(string num, string text)
|
||||||
|
{
|
||||||
|
var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("20,*") };
|
||||||
|
var lbl = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{num}.",
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 0, 4, 0),
|
||||||
|
};
|
||||||
|
Grid.SetColumn(lbl, 0);
|
||||||
|
grid.Children.Add(lbl);
|
||||||
|
|
||||||
|
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||||
|
AppendInlines(tb.Inlines!, text);
|
||||||
|
Grid.SetColumn(tb, 1);
|
||||||
|
grid.Children.Add(tb);
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendInlines(InlineCollection inlines, string text)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
var plain = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
void FlushPlain()
|
||||||
|
{
|
||||||
|
if (plain.Length == 0) return;
|
||||||
|
inlines.Add(new Run(plain.ToString()));
|
||||||
|
plain.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < text.Length)
|
||||||
|
{
|
||||||
|
if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*')
|
||||||
|
{
|
||||||
|
int close = text.IndexOf("**", i + 2, System.StringComparison.Ordinal);
|
||||||
|
if (close > i + 2)
|
||||||
|
{
|
||||||
|
FlushPlain();
|
||||||
|
inlines.Add(new Run(text[(i + 2)..close]) { FontWeight = FontWeight.Bold });
|
||||||
|
i = close + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text[i] == '*')
|
||||||
|
{
|
||||||
|
int close = text.IndexOf('*', i + 1);
|
||||||
|
if (close > i + 1)
|
||||||
|
{
|
||||||
|
FlushPlain();
|
||||||
|
inlines.Add(new Run(text[(i + 1)..close]) { FontStyle = FontStyle.Italic });
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text[i] == '`')
|
||||||
|
{
|
||||||
|
int close = text.IndexOf('`', i + 1);
|
||||||
|
if (close > i + 1)
|
||||||
|
{
|
||||||
|
FlushPlain();
|
||||||
|
inlines.Add(new Run(text[(i + 1)..close])
|
||||||
|
{
|
||||||
|
FontFamily = new FontFamily("Consolas,Menlo,monospace"),
|
||||||
|
Background = new SolidColorBrush(Color.FromArgb(60, 127, 127, 127)),
|
||||||
|
});
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plain.Append(text[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
FlushPlain();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
x:DataType="vm:DetailsIslandViewModel">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
@@ -138,6 +139,36 @@
|
|||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
|
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||||
|
<Border Padding="18,12,18,12"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding Task.IsPlanningParent}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE" Margin="0,0,0,2"/>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Merge target"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFaintBrush}"/>
|
||||||
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Review combined diff"
|
||||||
|
Command="{Binding ReviewCombinedDiffCommand}"/>
|
||||||
|
<Button Content="Merge all subtasks"
|
||||||
|
IsEnabled="{Binding CanMergeAll}"
|
||||||
|
Command="{Binding MergeAllCommand}"
|
||||||
|
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding MergeAllError}"
|
||||||
|
Foreground="OrangeRed"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Steps section -->
|
<!-- Steps section -->
|
||||||
<Border Padding="18,12,18,12"
|
<Border Padding="18,12,18,12"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
@@ -184,6 +215,77 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Details (description) section -->
|
||||||
|
<Border Padding="18,12,18,12"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Classes="flat"
|
||||||
|
Command="{Binding ToggleDescriptionExpandedCommand}"
|
||||||
|
Padding="0"
|
||||||
|
Margin="0,0,6,2"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Text="▾" FontSize="10"
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="▸" FontSize="10"
|
||||||
|
IsVisible="{Binding !IsDescriptionExpanded}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Classes="section-label" Text="DETAILS"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Padding="6,2"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
ToolTip.Tip="Copy description to clipboard"
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"
|
||||||
|
Click="OnCopyDescriptionClick">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Command="{Binding ToggleEditDescriptionCommand}"
|
||||||
|
Padding="6,2"
|
||||||
|
FontSize="10"
|
||||||
|
ToolTip.Tip="Toggle edit/preview"
|
||||||
|
IsVisible="{Binding IsDescriptionEditorVisible}">
|
||||||
|
<TextBlock Text="Preview"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Command="{Binding ToggleEditDescriptionCommand}"
|
||||||
|
Padding="6,2"
|
||||||
|
FontSize="10"
|
||||||
|
ToolTip.Tip="Toggle edit/preview"
|
||||||
|
IsVisible="{Binding IsDescriptionPreviewVisible}">
|
||||||
|
<TextBlock Text="Edit"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MinHeight="80"
|
||||||
|
MaxHeight="320"
|
||||||
|
PlaceholderText="Add task details (markdown supported)..."
|
||||||
|
Padding="8"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="12"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="6"
|
||||||
|
IsVisible="{Binding IsDescriptionEditorVisible}"/>
|
||||||
|
|
||||||
|
<ctl:MarkdownView Markdown="{Binding EditableDescription}"
|
||||||
|
IsVisible="{Binding IsDescriptionPreviewVisible}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
||||||
<islands:SessionTerminalView MaxHeight="420"/>
|
<islands:SessionTerminalView MaxHeight="420"/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.Views.Modals;
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
|
using ClaudeDo.Ui.Views.Planning;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
@@ -44,6 +46,14 @@ public partial class DetailsIslandView : UserControl
|
|||||||
await modal.ShowDialog(owner);
|
await modal.ShowDialog(owner);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner == null) return;
|
||||||
|
var modal = new PlanningDiffView { DataContext = planningDiffVm };
|
||||||
|
await modal.ShowDialog(owner);
|
||||||
|
};
|
||||||
|
|
||||||
vm.ConfirmAsync = ShowConfirmAsync;
|
vm.ConfirmAsync = ShowConfirmAsync;
|
||||||
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||||
}
|
}
|
||||||
@@ -138,4 +148,12 @@ public partial class DetailsIslandView : UserControl
|
|||||||
if (DataContext is DetailsIslandViewModel vm)
|
if (DataContext is DetailsIslandViewModel vm)
|
||||||
vm.SaveNotesCommand.Execute(null);
|
vm.SaveNotesCommand.Execute(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||||
|
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||||
|
if (clipboard is null) return;
|
||||||
|
await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<!-- + New list button -->
|
<!-- + New list button -->
|
||||||
<Button Classes="new-list-btn" Margin="0,4,0,0">
|
<Button Classes="new-list-btn" Margin="0,4,0,0"
|
||||||
|
Command="{Binding CreateListCommand}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<PathIcon Data="{StaticResource Icon.Plus}"
|
<PathIcon Data="{StaticResource Icon.Plus}"
|
||||||
Width="13" Height="13"
|
Width="13" Height="13"
|
||||||
|
|||||||
@@ -38,18 +38,20 @@
|
|||||||
IsVisible="{Binding IsQueued}"
|
IsVisible="{Binding IsQueued}"
|
||||||
Click="OnRemoveFromQueueClick"/>
|
Click="OnRemoveFromQueueClick"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
<MenuItem Header="Run interactively"
|
||||||
|
Click="OnRunInteractivelyClick"/>
|
||||||
<MenuItem Header="Open planning Session"
|
<MenuItem Header="Open planning Session"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).OpenPlanningSessionCommand}"
|
Click="OnOpenPlanningSessionClick"
|
||||||
CommandParameter="{Binding}"
|
|
||||||
IsVisible="{Binding CanOpenPlanningSession}"/>
|
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||||
<MenuItem Header="Resume planning Session"
|
<MenuItem Header="Resume planning Session"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ResumePlanningSessionCommand}"
|
Click="OnResumePlanningSessionClick"
|
||||||
CommandParameter="{Binding}"
|
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
<MenuItem Header="Discard planning session"
|
<MenuItem Header="Discard planning session"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).DiscardPlanningSessionCommand}"
|
Click="OnDiscardPlanningSessionClick"
|
||||||
CommandParameter="{Binding}"
|
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="Queue subtasks sequentially"
|
||||||
|
Click="OnQueuePlanningSubtasksClick"
|
||||||
|
IsVisible="{Binding HasPlanningChildren}"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
<MenuItem Header="Schedule for..." Click="OnScheduleForClick"/>
|
||||||
<MenuItem Header="Clear schedule"
|
<MenuItem Header="Clear schedule"
|
||||||
|
|||||||
@@ -39,6 +39,36 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRunInteractivelyClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.RunInteractivelyCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.ResumePlanningSessionCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnDiscardPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
private void OnScheduleForClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is not TaskRowViewModel row) return;
|
if (DataContext is not TaskRowViewModel row) return;
|
||||||
|
|||||||
@@ -141,15 +141,42 @@
|
|||||||
</Border.Background>
|
</Border.Background>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Three islands -->
|
<!-- Three islands (user-resizable) -->
|
||||||
<Grid Grid.Row="2" Margin="7" ColumnDefinitions="260,*,320">
|
<Grid Grid.Row="2" Margin="7">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="260" MinWidth="200"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="320"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="320" MinWidth="280"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Border Grid.Column="0" Classes="island" Margin="7">
|
<Border Grid.Column="0" Classes="island" Margin="7">
|
||||||
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
<islands:ListsIslandView DataContext="{Binding Lists}"/>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Grid.Column="1" Classes="island" Margin="7">
|
|
||||||
|
<GridSplitter Grid.Column="1"
|
||||||
|
Width="3"
|
||||||
|
Background="Transparent"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
ResizeDirection="Columns"
|
||||||
|
ResizeBehavior="PreviousAndNext"/>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Classes="island" Margin="7">
|
||||||
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
<islands:TasksIslandView DataContext="{Binding Tasks}"/>
|
||||||
</Border>
|
</Border>
|
||||||
<Border Grid.Column="2" Classes="island" Margin="7"
|
|
||||||
|
<GridSplitter Grid.Column="3"
|
||||||
|
Width="3"
|
||||||
|
Background="Transparent"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
ResizeDirection="Columns"
|
||||||
|
ResizeBehavior="PreviousAndNext"
|
||||||
|
IsVisible="{Binding ShowDetails}"/>
|
||||||
|
|
||||||
|
<Border Grid.Column="4" Classes="island" Margin="7"
|
||||||
IsVisible="{Binding ShowDetails}">
|
IsVisible="{Binding ShowDetails}">
|
||||||
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
<islands:DetailsIslandView DataContext="{Binding Details}"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
|
using ClaudeDo.Ui.Views.Planning;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views;
|
namespace ClaudeDo.Ui.Views;
|
||||||
|
|
||||||
@@ -10,6 +11,19 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is IslandsShellViewModel vm)
|
||||||
|
{
|
||||||
|
vm.ShowConflictDialog = async (conflictVm) =>
|
||||||
|
{
|
||||||
|
var modal = new ConflictResolutionView { DataContext = conflictVm };
|
||||||
|
await modal.ShowDialog(this);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
|
|
||||||
<Window.Styles>
|
<Window.Styles>
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="Name"/>
|
<TextBlock Classes="field-label" Text="Name"/>
|
||||||
<TextBox Text="{Binding Name}" />
|
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
|
|||||||
@@ -201,6 +201,38 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- PROMPTS -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="PROMPTS"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="90,*,Auto" RowSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="System"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono"
|
||||||
|
Text="{Binding SystemPromptPath}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Row="0" Grid.Column="2" Content="Open in editor"
|
||||||
|
Command="{Binding OpenPromptCommand}"
|
||||||
|
CommandParameter="System"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Classes="field-label" Text="Planning"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Classes="path-mono"
|
||||||
|
Text="{Binding PlanningPromptPath}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Row="1" Grid.Column="2" Content="Open in editor"
|
||||||
|
Command="{Binding OpenPromptCommand}"
|
||||||
|
CommandParameter="Planning"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Classes="field-label" Text="Agent"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1" Classes="path-mono"
|
||||||
|
Text="{Binding AgentPromptPath}" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Row="2" Grid.Column="2" Content="Open in editor"
|
||||||
|
Command="{Binding OpenPromptCommand}"
|
||||||
|
CommandParameter="Agent"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ABOUT -->
|
<!-- ABOUT -->
|
||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
<TextBlock Classes="section-label" Text="ABOUT"/>
|
<TextBlock Classes="section-label" Text="ABOUT"/>
|
||||||
|
|||||||
65
src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
Normal file
65
src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
||||||
|
x:DataType="vm:ConflictResolutionViewModel"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
||||||
|
Title="Merge conflict"
|
||||||
|
Width="560" SizeToContent="Height"
|
||||||
|
SystemDecorations="None"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<Border Background="{StaticResource SurfaceBrush}"
|
||||||
|
BorderBrush="{StaticResource LineBrush}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid RowDefinitions="36,*">
|
||||||
|
|
||||||
|
<!-- Title bar / drag handle -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
x:Name="TitleBar"
|
||||||
|
Background="{StaticResource Surface2Brush}"
|
||||||
|
BorderBrush="{StaticResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
PointerPressed="TitleBar_PointerPressed">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||||
|
<TextBlock Text="Merge conflict"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontFamily="{StaticResource MonoFamily}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource TextDimBrush}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<StackPanel Grid.Row="1" Spacing="12" Margin="16" MinWidth="520">
|
||||||
|
<TextBlock FontWeight="SemiBold" FontSize="16"
|
||||||
|
Text="{Binding SubtaskTitle, StringFormat='Conflicts in subtask: {0}'}"/>
|
||||||
|
<TextBlock Text="{Binding TargetBranch, StringFormat='Merging into: {0}'}" Opacity="0.7"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding}" FontFamily="Consolas,Menlo,monospace"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="{Binding VsCodeError}" Foreground="OrangeRed"
|
||||||
|
IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<TextBlock Text="{Binding ActionError}" Foreground="OrangeRed"
|
||||||
|
IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
|
TextWrapping="Wrap"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,4,0,4">
|
||||||
|
<Button Content="Open all in VS Code" Command="{Binding OpenInVsCodeCommand}"/>
|
||||||
|
<Button Content="I've resolved — continue" Command="{Binding ContinueCommand}"/>
|
||||||
|
<Button Content="Abort this merge" Command="{Binding AbortCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Planning;
|
||||||
|
|
||||||
|
public partial class ConflictResolutionView : Window
|
||||||
|
{
|
||||||
|
public ConflictResolutionView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ConflictResolutionViewModel vm)
|
||||||
|
vm.CloseRequested = Close;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml
Normal file
109
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
|
||||||
|
x:DataType="vm:PlanningDiffViewModel"
|
||||||
|
Title="Planning — Combined diff"
|
||||||
|
Width="1100" Height="700"
|
||||||
|
SystemDecorations="None"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<Border Background="{StaticResource SurfaceBrush}"
|
||||||
|
BorderBrush="{StaticResource LineBrush}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid RowDefinitions="36,Auto,*">
|
||||||
|
|
||||||
|
<!-- Title bar / drag handle -->
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
x:Name="TitleBar"
|
||||||
|
Background="{StaticResource Surface2Brush}"
|
||||||
|
BorderBrush="{StaticResource LineBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
PointerPressed="TitleBar_PointerPressed">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||||
|
<TextBlock Text="Planning — Combined diff"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontFamily="{StaticResource MonoFamily}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource TextDimBrush}"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Content="✕"
|
||||||
|
FontSize="12"
|
||||||
|
Command="{Binding CloseCommand}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Toolbar row -->
|
||||||
|
<StackPanel Grid.Row="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
Margin="8,6">
|
||||||
|
<ToggleButton Content="Preview combined" IsChecked="{Binding IsCombinedMode}"/>
|
||||||
|
<TextBlock Text="{Binding CombinedWarning}"
|
||||||
|
Foreground="Orange"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding CombinedWarning, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
<TextBlock Text="Loading…"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding IsLoadingCombined}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Two-pane body -->
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="240,*">
|
||||||
|
|
||||||
|
<!-- Subtask list (left pane) -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
BorderBrush="{StaticResource LineBrush}"
|
||||||
|
BorderThickness="0,0,1,0"
|
||||||
|
Background="{StaticResource DeepBrush}">
|
||||||
|
<ListBox ItemsSource="{Binding Subtasks}"
|
||||||
|
SelectedItem="{Binding SelectedSubtask}"
|
||||||
|
IsEnabled="{Binding !IsCombinedMode}"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SubtaskDiffRow">
|
||||||
|
<Border Padding="10,8" Background="Transparent">
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding DiffStat}"
|
||||||
|
Opacity="0.7"
|
||||||
|
FontFamily="{StaticResource MonoFamily}"
|
||||||
|
FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Diff content (right pane) -->
|
||||||
|
<Grid Grid.Column="1" Background="{StaticResource VoidBrush}">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<TextBox Text="{Binding DisplayedDiff, Mode=OneWay}"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
FontFamily="Consolas,Menlo,monospace"
|
||||||
|
FontSize="12"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="8"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
27
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml.cs
Normal file
27
src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Planning;
|
||||||
|
|
||||||
|
public partial class PlanningDiffView : Window
|
||||||
|
{
|
||||||
|
public PlanningDiffView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is PlanningDiffViewModel vm)
|
||||||
|
vm.CloseAction = Close;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
|||||||
|
|
||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`. Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
|
||||||
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||||
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
|
||||||
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
|
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
|
||||||
@@ -64,5 +64,5 @@ Per-list config (`list_config` in DB) provides defaults for `model`, `system_pro
|
|||||||
|
|
||||||
- The worker runs standalone — start it separately from the UI
|
- The worker runs standalone — start it separately from the UI
|
||||||
- Only listens on loopback (127.0.0.1)
|
- Only listens on loopback (127.0.0.1)
|
||||||
- ClaudeProcess uses `--dangerously-skip-permissions` — tasks run with full filesystem access
|
- ClaudeProcess uses `--permission-mode auto` by default; legacy "bypassPermissions" settings are mapped to `auto` at dispatch time. `acceptEdits`, `plan`, and `default` pass through unchanged.
|
||||||
- Worktree branches follow `claudedo/{id}` naming convention
|
- Worktree branches follow `claudedo/{id}` naming convention
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
|
||||||
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ public sealed class WorkerConfig
|
|||||||
[JsonPropertyName("claude_bin")]
|
[JsonPropertyName("claude_bin")]
|
||||||
public string ClaudeBin { get; set; } = "claude";
|
public string ClaudeBin { get; set; } = "claude";
|
||||||
|
|
||||||
|
/// <summary>Port for the external MCP endpoint. 0 disables the external listener entirely.</summary>
|
||||||
|
[JsonPropertyName("external_mcp_port")]
|
||||||
|
public int ExternalMcpPort { get; set; } = 47_822;
|
||||||
|
|
||||||
|
/// <summary>Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only.</summary>
|
||||||
|
[JsonPropertyName("external_mcp_api_key")]
|
||||||
|
public string? ExternalMcpApiKey { get; set; }
|
||||||
|
|
||||||
public static string DefaultConfigPath =>
|
public static string DefaultConfigPath =>
|
||||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||||
|
|
||||||
|
|||||||
32
src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
vendored
Normal file
32
src/ClaudeDo.Worker/External/ExternalMcpAuthMiddleware.cs
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
public sealed class ExternalMcpAuthMiddleware
|
||||||
|
{
|
||||||
|
private const string HeaderName = "X-ClaudeDo-Key";
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public ExternalMcpAuthMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, WorkerConfig cfg)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(cfg.ExternalMcpApiKey))
|
||||||
|
{
|
||||||
|
await _next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provided = ctx.Request.Headers[HeaderName].ToString();
|
||||||
|
if (!string.Equals(provided, cfg.ExternalMcpApiKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync($"Missing or invalid {HeaderName} header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal file
197
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.External;
|
||||||
|
|
||||||
|
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
||||||
|
|
||||||
|
public sealed record TaskDto(
|
||||||
|
string Id,
|
||||||
|
string ListId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
string Status,
|
||||||
|
string? Result,
|
||||||
|
string? CreatedBy,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? StartedAt,
|
||||||
|
DateTime? FinishedAt);
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class ExternalMcpService
|
||||||
|
{
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly QueueService _queue;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public ExternalMcpService(
|
||||||
|
TaskRepository tasks,
|
||||||
|
ListRepository lists,
|
||||||
|
QueueService queue,
|
||||||
|
HubBroadcaster broadcaster)
|
||||||
|
{
|
||||||
|
_tasks = tasks;
|
||||||
|
_lists = lists;
|
||||||
|
_queue = queue;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
||||||
|
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var lists = await _lists.GetAllAsync(cancellationToken);
|
||||||
|
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("List tasks in a given list. Optionally filter by creator (CreatedBy) and/or status.")]
|
||||||
|
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
||||||
|
string listId,
|
||||||
|
string? createdBy,
|
||||||
|
string? status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
TaskStatus? statusFilter = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(status))
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||||
|
throw new InvalidOperationException($"Unknown status '{status}'.");
|
||||||
|
statusFilter = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
|
||||||
|
IEnumerable<TaskEntity> query = tasks;
|
||||||
|
if (createdBy is not null)
|
||||||
|
query = query.Where(t => t.CreatedBy == createdBy);
|
||||||
|
if (statusFilter is not null)
|
||||||
|
query = query.Where(t => t.Status == statusFilter);
|
||||||
|
|
||||||
|
return query.Select(ToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Get a single task by id, including its current status and result.")]
|
||||||
|
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
return ToDto(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
|
||||||
|
public async Task<TaskDto> AddTask(
|
||||||
|
string listId,
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
string createdBy,
|
||||||
|
bool queueImmediately,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(listId))
|
||||||
|
throw new InvalidOperationException("listId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
throw new InvalidOperationException("title is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(createdBy))
|
||||||
|
throw new InvalidOperationException("createdBy is required.");
|
||||||
|
|
||||||
|
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||||
|
|
||||||
|
var entity = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = list.DefaultCommitType,
|
||||||
|
CreatedBy = createdBy,
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(entity, cancellationToken);
|
||||||
|
|
||||||
|
if (queueImmediately)
|
||||||
|
_queue.WakeQueue();
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(entity.Id);
|
||||||
|
return ToDto(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Update a task's status. Only 'Manual' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")]
|
||||||
|
public async Task<TaskDto> UpdateTaskStatus(
|
||||||
|
string taskId,
|
||||||
|
string status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
|
||||||
|
throw new InvalidOperationException($"Unknown status '{status}'.");
|
||||||
|
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
|
switch (target)
|
||||||
|
{
|
||||||
|
case TaskStatus.Manual:
|
||||||
|
await _tasks.ResetToManualAsync(taskId, cancellationToken);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TaskStatus.Queued:
|
||||||
|
if (task.Status is TaskStatus.Running)
|
||||||
|
throw new InvalidOperationException("Cannot enqueue a running task.");
|
||||||
|
task.Status = TaskStatus.Queued;
|
||||||
|
await _tasks.UpdateAsync(task, cancellationToken);
|
||||||
|
_queue.WakeQueue();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return ToDto(reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
|
||||||
|
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _queue.RunNow(taskId);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Override slot busy. Try again later.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
}
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")]
|
||||||
|
public async Task<bool> CancelTask(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cancelled = _queue.CancelTask(taskId);
|
||||||
|
if (cancelled) await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskDto ToDto(TaskEntity t) => new(
|
||||||
|
t.Id,
|
||||||
|
t.ListId,
|
||||||
|
t.Title,
|
||||||
|
t.Description,
|
||||||
|
t.Status.ToString(),
|
||||||
|
t.Result,
|
||||||
|
t.CreatedBy,
|
||||||
|
t.CreatedAt,
|
||||||
|
t.StartedAt,
|
||||||
|
t.FinishedAt);
|
||||||
|
}
|
||||||
@@ -32,4 +32,19 @@ public sealed class HubBroadcaster
|
|||||||
|
|
||||||
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) =>
|
||||||
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
_hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc);
|
||||||
|
|
||||||
|
public Task PlanningMergeStarted(string planningTaskId, string targetBranch) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch);
|
||||||
|
|
||||||
|
public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId);
|
||||||
|
|
||||||
|
public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files);
|
||||||
|
|
||||||
|
public Task PlanningMergeAborted(string planningTaskId) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId);
|
||||||
|
|
||||||
|
public Task PlanningCompleted(string planningTaskId) =>
|
||||||
|
_hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -43,6 +44,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||||
private readonly TaskResetService _resetService;
|
private readonly TaskResetService _resetService;
|
||||||
private readonly TaskMergeService _mergeService;
|
private readonly TaskMergeService _mergeService;
|
||||||
|
private readonly PlanningSessionManager _planning;
|
||||||
|
private readonly IPlanningTerminalLauncher _launcher;
|
||||||
|
private readonly PlanningAggregator _planningAggregator;
|
||||||
|
private readonly PlanningMergeOrchestrator _planningMergeOrchestrator;
|
||||||
|
private readonly PlanningChainCoordinator _planningChain;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -52,7 +58,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
WorktreeMaintenanceService wtMaintenance,
|
WorktreeMaintenanceService wtMaintenance,
|
||||||
TaskResetService resetService,
|
TaskResetService resetService,
|
||||||
TaskMergeService mergeService)
|
TaskMergeService mergeService,
|
||||||
|
PlanningSessionManager planning,
|
||||||
|
IPlanningTerminalLauncher launcher,
|
||||||
|
PlanningAggregator planningAggregator,
|
||||||
|
PlanningMergeOrchestrator planningMergeOrchestrator,
|
||||||
|
PlanningChainCoordinator planningChain)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_agentService = agentService;
|
_agentService = agentService;
|
||||||
@@ -62,6 +73,34 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_wtMaintenance = wtMaintenance;
|
_wtMaintenance = wtMaintenance;
|
||||||
_resetService = resetService;
|
_resetService = resetService;
|
||||||
_mergeService = mergeService;
|
_mergeService = mergeService;
|
||||||
|
_planning = planning;
|
||||||
|
_launcher = launcher;
|
||||||
|
_planningAggregator = planningAggregator;
|
||||||
|
_planningMergeOrchestrator = planningMergeOrchestrator;
|
||||||
|
_planningChain = planningChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _planningChain.QueueSubtasksSequentiallyAsync(parentTaskId, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
throw new HubException(ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var childIds = await ctx.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
await _broadcaster.TaskUpdated(parentTaskId);
|
||||||
|
foreach (var id in childIds)
|
||||||
|
await _broadcaster.TaskUpdated(id);
|
||||||
|
|
||||||
|
_queue.WakeQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Ping() => $"pong v{Version}";
|
public string Ping() => $"pong v{Version}";
|
||||||
@@ -284,5 +323,100 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
await _broadcaster.TaskUpdated(dto.TaskId);
|
await _broadcaster.TaskUpdated(dto.TaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
catch (PlanningLaunchException)
|
||||||
|
{
|
||||||
|
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
||||||
|
await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenInteractiveTerminalAsync(string taskId)
|
||||||
|
{
|
||||||
|
var ctx = await _planning.OpenInteractiveAsync(taskId, Context.ConnectionAborted);
|
||||||
|
await _launcher.LaunchInteractiveAsync(ctx, Context.ConnectionAborted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DiscardPlanningSessionAsync(string taskId)
|
||||||
|
{
|
||||||
|
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
|
||||||
|
{
|
||||||
|
var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
|
||||||
|
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId)
|
||||||
|
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregate(string planningTaskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var diffs = await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None);
|
||||||
|
return diffs.Select(d => new SubtaskDiffDto(
|
||||||
|
d.SubtaskId, d.Title, d.BranchName, d.BaseCommit, d.HeadCommit, d.DiffStat, d.UnifiedDiff)).ToList();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
|
||||||
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CombinedDiffResultDto> BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _planningAggregator.BuildIntegrationBranchAsync(
|
||||||
|
planningTaskId, targetBranch ?? "", CancellationToken.None);
|
||||||
|
return result switch
|
||||||
|
{
|
||||||
|
CombinedDiffResult.Ok ok => new CombinedDiffResultDto(
|
||||||
|
true, ok.Value.IntegrationBranch, ok.Value.UnifiedDiff, null, null),
|
||||||
|
CombinedDiffResult.Failed f => new CombinedDiffResultDto(
|
||||||
|
false, null, null, f.Value.FirstConflictSubtaskId, f.Value.ConflictedFiles),
|
||||||
|
_ => throw new InvalidOperationException("unknown result type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
|
||||||
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MergeAllPlanning(string planningTaskId, string targetBranch)
|
||||||
|
{
|
||||||
|
try { await _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None); }
|
||||||
|
catch (KeyNotFoundException) { throw new HubException("planning task not found"); }
|
||||||
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ContinuePlanningMerge(string planningTaskId)
|
||||||
|
{
|
||||||
|
try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
|
||||||
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AbortPlanningMerge(string planningTaskId)
|
||||||
|
{
|
||||||
|
try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); }
|
||||||
|
catch (InvalidOperationException ex) { throw new HubException(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
13
src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public interface IPlanningTerminalLauncher
|
||||||
|
{
|
||||||
|
Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
|
||||||
|
Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
|
||||||
|
Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningLaunchException : Exception
|
||||||
|
{
|
||||||
|
public PlanningLaunchException(string message) : base(message) { }
|
||||||
|
}
|
||||||
6
src/ClaudeDo.Worker/Planning/InteractiveLaunchContext.cs
Normal file
6
src/ClaudeDo.Worker/Planning/InteractiveLaunchContext.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record InteractiveLaunchContext(
|
||||||
|
string TaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string InitialPrompt);
|
||||||
186
src/ClaudeDo.Worker/Planning/PlanningAggregator.cs
Normal file
186
src/ClaudeDo.Worker/Planning/PlanningAggregator.cs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record SubtaskDiff(
|
||||||
|
string SubtaskId,
|
||||||
|
string Title,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
string HeadCommit,
|
||||||
|
string? DiffStat,
|
||||||
|
string UnifiedDiff);
|
||||||
|
|
||||||
|
public sealed record CombinedDiffSuccess(string IntegrationBranch, string UnifiedDiff);
|
||||||
|
public sealed record CombinedDiffFailure(string FirstConflictSubtaskId, IReadOnlyList<string> ConflictedFiles);
|
||||||
|
|
||||||
|
public abstract record CombinedDiffResult
|
||||||
|
{
|
||||||
|
public sealed record Ok(CombinedDiffSuccess Value) : CombinedDiffResult;
|
||||||
|
public sealed record Failed(CombinedDiffFailure Value) : CombinedDiffResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningAggregator
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly ILogger<PlanningAggregator> _logger;
|
||||||
|
|
||||||
|
public PlanningAggregator(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
GitService git,
|
||||||
|
ILogger<PlanningAggregator> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_git = git;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SubtaskDiff>> GetAggregatedDiffAsync(
|
||||||
|
string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var children = await ctx.Tasks
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.Where(t => t.ParentTaskId == planningTaskId)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var result = new List<SubtaskDiff>();
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
if (child.Worktree is null) continue;
|
||||||
|
var wt = child.Worktree;
|
||||||
|
var head = wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct);
|
||||||
|
string unified;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "diff failed for subtask {Id}", child.Id);
|
||||||
|
unified = "";
|
||||||
|
}
|
||||||
|
result.Add(new SubtaskDiff(
|
||||||
|
child.Id, child.Title, wt.BranchName, wt.BaseCommit, head, wt.DiffStat, unified));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CombinedDiffResult> BuildIntegrationBranchAsync(
|
||||||
|
string planningTaskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct);
|
||||||
|
|
||||||
|
var integrationBranch = BuildIntegrationBranchName(planning);
|
||||||
|
|
||||||
|
// Reset: checkout target first (so we're never ON the integration branch when deleting it),
|
||||||
|
// then delete if exists, then recreate off the target branch.
|
||||||
|
await _git.CheckoutBranchAsync(repoDir, targetBranch, ct);
|
||||||
|
|
||||||
|
try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); }
|
||||||
|
catch { /* didn't exist */ }
|
||||||
|
|
||||||
|
await GitRawAsync(repoDir, ct, "checkout", "-b", integrationBranch);
|
||||||
|
|
||||||
|
foreach (var child in childSubtasks)
|
||||||
|
{
|
||||||
|
if (child.Worktree is null) continue;
|
||||||
|
var (code, _) = await _git.MergeNoFfAsync(
|
||||||
|
repoDir, child.Worktree.BranchName,
|
||||||
|
$"Integrate subtask: {child.Title}", ct);
|
||||||
|
if (code != 0)
|
||||||
|
{
|
||||||
|
List<string> files;
|
||||||
|
try { files = await _git.ListConflictedFilesAsync(repoDir, ct); }
|
||||||
|
catch { files = new(); }
|
||||||
|
|
||||||
|
try { await _git.MergeAbortAsync(repoDir, ct); } catch { }
|
||||||
|
try { await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); } catch { }
|
||||||
|
try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { }
|
||||||
|
|
||||||
|
return new CombinedDiffResult.Failed(
|
||||||
|
new CombinedDiffFailure(child.Id, files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var unifiedDiff = await GitRawAsync(repoDir, ct, "diff", $"{targetBranch}..{integrationBranch}");
|
||||||
|
return new CombinedDiffResult.Ok(new CombinedDiffSuccess(integrationBranch, unifiedDiff));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CleanupIntegrationBranchAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (planning, repoDir, _) = await LoadPlanningContextAsync(planningTaskId, ct);
|
||||||
|
var branch = BuildIntegrationBranchName(planning);
|
||||||
|
|
||||||
|
var current = await _git.GetCurrentBranchAsync(repoDir, ct);
|
||||||
|
if (string.Equals(current, branch, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var branches = await _git.ListLocalBranchesAsync(repoDir, ct);
|
||||||
|
var target = branches.FirstOrDefault(b => b != branch && !b.StartsWith("claudedo/", StringComparison.Ordinal)) ?? "main";
|
||||||
|
await _git.CheckoutBranchAsync(repoDir, target, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); }
|
||||||
|
catch { /* already gone — idempotent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(TaskEntity planning, string repoDir, IReadOnlyList<TaskEntity> children)>
|
||||||
|
LoadPlanningContextAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var planning = await ctx.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Children).ThenInclude(c => c.Worktree)
|
||||||
|
.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found.");
|
||||||
|
var repoDir = planning.List.WorkingDir
|
||||||
|
?? throw new InvalidOperationException("List has no working directory.");
|
||||||
|
var children = planning.Children.OrderBy(c => c.SortOrder).ToList();
|
||||||
|
return (planning, repoDir, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildIntegrationBranchName(TaskEntity planning)
|
||||||
|
{
|
||||||
|
var slug = new string(planning.Title
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
|
||||||
|
.ToArray())
|
||||||
|
.Trim('-');
|
||||||
|
if (string.IsNullOrEmpty(slug)) slug = planning.Id[..8];
|
||||||
|
if (slug.Length > 40) slug = slug[..40].TrimEnd('-');
|
||||||
|
return $"planning/{slug}-integration";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> GitRawAsync(string cwd, CancellationToken ct, params string[] args)
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo("git")
|
||||||
|
{
|
||||||
|
WorkingDirectory = cwd,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||||
|
using var p = System.Diagnostics.Process.Start(psi)!;
|
||||||
|
var stdoutTask = p.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderrTask = p.StandardError.ReadToEndAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await p.WaitForExitAsync(ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
try { if (!p.HasExited) p.Kill(entireProcessTree: true); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
var stdout = await stdoutTask;
|
||||||
|
var stderr = await stderrTask;
|
||||||
|
if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs
Normal file
63
src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningChainCoordinator
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public PlanningChainCoordinator(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
|
=> _dbFactory = dbFactory;
|
||||||
|
|
||||||
|
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
|
||||||
|
|
||||||
|
var children = await ctx.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (children.Count == 0)
|
||||||
|
throw new InvalidOperationException("Parent has no subtasks.");
|
||||||
|
|
||||||
|
var bad = children.FirstOrDefault(c =>
|
||||||
|
c.Status != TaskStatus.Manual && c.Status != TaskStatus.Planned);
|
||||||
|
if (bad is not null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
|
||||||
|
|
||||||
|
for (int i = 0; i < children.Count; i++)
|
||||||
|
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> OnChildFinishedAsync(
|
||||||
|
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (finalStatus != TaskStatus.Done) return null;
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var child = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == childTaskId, ct);
|
||||||
|
if (child?.ParentTaskId is null) return null;
|
||||||
|
|
||||||
|
var next = await ctx.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == child.ParentTaskId
|
||||||
|
&& t.SortOrder > child.SortOrder
|
||||||
|
&& t.Status == TaskStatus.Waiting)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (next is null) return null;
|
||||||
|
|
||||||
|
next.Status = TaskStatus.Queued;
|
||||||
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
return next.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
6
src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningMcpContext
|
||||||
|
{
|
||||||
|
public required string ParentTaskId { get; init; }
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
14
src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningMcpContextAccessor
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
|
public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http;
|
||||||
|
|
||||||
|
public PlanningMcpContext Current =>
|
||||||
|
(_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext)
|
||||||
|
?? throw new InvalidOperationException("No planning context on request.");
|
||||||
|
}
|
||||||
135
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
135
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
|
||||||
|
public sealed record CreatedChildDto(string TaskId, string Status);
|
||||||
|
|
||||||
|
[McpServerToolType]
|
||||||
|
public sealed class PlanningMcpService
|
||||||
|
{
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly PlanningMcpContextAccessor _contextAccessor;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
|
||||||
|
public PlanningMcpService(
|
||||||
|
TaskRepository tasks,
|
||||||
|
PlanningMcpContextAccessor contextAccessor,
|
||||||
|
HubBroadcaster broadcaster)
|
||||||
|
{
|
||||||
|
_tasks = tasks;
|
||||||
|
_contextAccessor = contextAccessor;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||||
|
=> _broadcaster.TaskUpdated(taskId);
|
||||||
|
|
||||||
|
[McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")]
|
||||||
|
public async Task<CreatedChildDto> CreateChildTask(
|
||||||
|
string title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tags,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return new CreatedChildDto(child.Id, "Draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
|
||||||
|
public async Task<IReadOnlyList<ChildTaskDto>> ListChildTasks(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
var list = new List<ChildTaskDto>(children.Count);
|
||||||
|
foreach (var c in children)
|
||||||
|
{
|
||||||
|
var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
|
||||||
|
list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")]
|
||||||
|
public async Task<ChildTaskDto> UpdateChildTask(
|
||||||
|
string taskId,
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<string>? tags,
|
||||||
|
string? commitType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||||
|
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||||
|
if (child.Status != TaskStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Cannot modify a finalized task.");
|
||||||
|
|
||||||
|
if (title is not null) child.Title = title;
|
||||||
|
if (description is not null) child.Description = description;
|
||||||
|
if (commitType is not null) child.CommitType = commitType;
|
||||||
|
await _tasks.UpdateAsync(child, cancellationToken);
|
||||||
|
|
||||||
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
|
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")]
|
||||||
|
public async Task DeleteChildTask(
|
||||||
|
string taskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (child.ParentTaskId != ctx.ParentTaskId)
|
||||||
|
throw new InvalidOperationException("Task is not a child of this planning session.");
|
||||||
|
if (child.Status != TaskStatus.Draft)
|
||||||
|
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||||
|
|
||||||
|
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Update the title and/or description of the parent planning task itself.")]
|
||||||
|
public async Task UpdatePlanningTask(
|
||||||
|
string? title,
|
||||||
|
string? description,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
||||||
|
public async Task<int> Finalize(
|
||||||
|
bool queueAgentTasks,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ctx = _contextAccessor.Current;
|
||||||
|
var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken))
|
||||||
|
.Select(c => c.Id).ToList();
|
||||||
|
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||||
|
foreach (var id in childIds)
|
||||||
|
await BroadcastTaskUpdatedAsync(id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs
Normal file
8
src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record PlanningMergeStarted(string PlanningTaskId, string TargetBranch);
|
||||||
|
public sealed record PlanningSubtaskMerged(string PlanningTaskId, string SubtaskId);
|
||||||
|
public sealed record PlanningMergeConflict(
|
||||||
|
string PlanningTaskId, string SubtaskId, IReadOnlyList<string> ConflictedFiles);
|
||||||
|
public sealed record PlanningMergeAborted(string PlanningTaskId);
|
||||||
|
public sealed record PlanningCompleted(string PlanningTaskId);
|
||||||
191
src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs
Normal file
191
src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningMergeOrchestrator
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly TaskMergeService _merge;
|
||||||
|
private readonly PlanningAggregator _aggregator;
|
||||||
|
private readonly HubBroadcaster _broadcaster;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly ILogger<PlanningMergeOrchestrator> _logger;
|
||||||
|
|
||||||
|
private sealed class State
|
||||||
|
{
|
||||||
|
public required string TargetBranch { get; init; }
|
||||||
|
public required Queue<string> RemainingSubtaskIds { get; init; }
|
||||||
|
public string? CurrentSubtaskId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, State> _states = new();
|
||||||
|
|
||||||
|
public PlanningMergeOrchestrator(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
TaskMergeService merge,
|
||||||
|
PlanningAggregator aggregator,
|
||||||
|
HubBroadcaster broadcaster,
|
||||||
|
GitService git,
|
||||||
|
ILogger<PlanningMergeOrchestrator> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_merge = merge;
|
||||||
|
_aggregator = aggregator;
|
||||||
|
_broadcaster = broadcaster;
|
||||||
|
_git = git;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string workingDir;
|
||||||
|
List<TaskEntity> children;
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var planning = await ctx.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Children).ThenInclude(c => c.Worktree)
|
||||||
|
.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found.");
|
||||||
|
workingDir = planning.List.WorkingDir
|
||||||
|
?? throw new InvalidOperationException("List has no working directory.");
|
||||||
|
children = planning.Children.OrderBy(c => c.SortOrder).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in children)
|
||||||
|
{
|
||||||
|
if (c.Status != TaskStatus.Done)
|
||||||
|
throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})");
|
||||||
|
if (c.Worktree is null)
|
||||||
|
throw new InvalidOperationException($"subtask {c.Id} has no worktree");
|
||||||
|
if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"subtask {c.Id} worktree state is {c.Worktree.State}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _git.IsMidMergeAsync(workingDir, ct))
|
||||||
|
throw new InvalidOperationException("repo is mid-merge");
|
||||||
|
if (await _git.HasChangesAsync(workingDir, ct))
|
||||||
|
throw new InvalidOperationException("working tree has uncommitted changes");
|
||||||
|
|
||||||
|
var queue = new Queue<string>(
|
||||||
|
children
|
||||||
|
.Where(c => c.Worktree!.State == WorktreeState.Active)
|
||||||
|
.Select(c => c.Id));
|
||||||
|
|
||||||
|
var state = new State { TargetBranch = targetBranch, RemainingSubtaskIds = queue };
|
||||||
|
if (!_states.TryAdd(planningTaskId, state))
|
||||||
|
throw new InvalidOperationException($"Merge already in progress for {planningTaskId}.");
|
||||||
|
|
||||||
|
await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch);
|
||||||
|
await DrainAsync(planningTaskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
|
throw new InvalidOperationException("no in-progress merge to continue");
|
||||||
|
|
||||||
|
var current = state.CurrentSubtaskId;
|
||||||
|
var result = await _merge.ContinueMergeAsync(current, ct);
|
||||||
|
|
||||||
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
|
{
|
||||||
|
await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Status != TaskMergeService.StatusMerged)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Planning continue blocked on subtask {Subtask}: {Msg}",
|
||||||
|
current, result.ErrorMessage);
|
||||||
|
_states.TryRemove(planningTaskId, out _);
|
||||||
|
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.PlanningSubtaskMerged(planningTaskId, current);
|
||||||
|
|
||||||
|
state.CurrentSubtaskId = null;
|
||||||
|
await DrainAsync(planningTaskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
|
throw new InvalidOperationException("no in-progress merge to abort");
|
||||||
|
|
||||||
|
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
|
||||||
|
_states.TryRemove(planningTaskId, out _);
|
||||||
|
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_states.TryGetValue(planningTaskId, out var state)) return;
|
||||||
|
|
||||||
|
var keepState = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (state.RemainingSubtaskIds.TryDequeue(out var subtaskId))
|
||||||
|
{
|
||||||
|
state.CurrentSubtaskId = subtaskId;
|
||||||
|
var result = await _merge.MergeAsync(
|
||||||
|
subtaskId,
|
||||||
|
state.TargetBranch,
|
||||||
|
removeWorktree: true,
|
||||||
|
commitMessage: "Merge subtask",
|
||||||
|
leaveConflictsInTree: true,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (result.Status == TaskMergeService.StatusConflict)
|
||||||
|
{
|
||||||
|
await _broadcaster.PlanningMergeConflict(planningTaskId, subtaskId, result.ConflictFiles);
|
||||||
|
keepState = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Status != TaskMergeService.StatusMerged)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Planning merge blocked on subtask {Subtask}: {Msg}",
|
||||||
|
subtaskId, result.ErrorMessage);
|
||||||
|
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
||||||
|
return; // keepState stays false → finally removes the state entry
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.CurrentSubtaskId = null;
|
||||||
|
await FinalizePlanningDoneAsync(planningTaskId, ct);
|
||||||
|
await _broadcaster.PlanningCompleted(planningTaskId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!keepState) _states.TryRemove(planningTaskId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinalizePlanningDoneAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var planning = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct);
|
||||||
|
if (planning is null) return;
|
||||||
|
planning.Status = TaskStatus.Done;
|
||||||
|
planning.FinishedAt = DateTime.UtcNow;
|
||||||
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
try { await _aggregator.CleanupIntegrationBranchAsync(planningTaskId, ct); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
Normal file
16
src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionStartContext(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string Token,
|
||||||
|
string WorktreePath,
|
||||||
|
string BranchName,
|
||||||
|
PlanningSessionFiles Files);
|
||||||
|
|
||||||
|
public sealed record PlanningSessionResumeContext(
|
||||||
|
string ParentTaskId,
|
||||||
|
string WorkingDir,
|
||||||
|
string ClaudeSessionId,
|
||||||
|
string Token,
|
||||||
|
string WorktreePath);
|
||||||
6
src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
Normal file
6
src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record PlanningSessionFiles(
|
||||||
|
string SessionDirectory,
|
||||||
|
string SystemPromptPath,
|
||||||
|
string InitialPromptPath);
|
||||||
415
src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
Normal file
415
src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningSessionManager
|
||||||
|
{
|
||||||
|
private string McpServerUrl => $"http://127.0.0.1:{_cfg.SignalRPort}/mcp";
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
||||||
|
private readonly TaskRepository? _tasksOverride;
|
||||||
|
private readonly ListRepository? _listsOverride;
|
||||||
|
private readonly AppSettingsRepository? _settingsOverride;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly string _rootDirectory;
|
||||||
|
|
||||||
|
// DI constructor.
|
||||||
|
public PlanningSessionManager(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> factory,
|
||||||
|
GitService git,
|
||||||
|
WorkerConfig cfg,
|
||||||
|
string rootDirectory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
_git = git;
|
||||||
|
_cfg = cfg;
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test constructor.
|
||||||
|
public PlanningSessionManager(
|
||||||
|
TaskRepository tasks,
|
||||||
|
ListRepository lists,
|
||||||
|
AppSettingsRepository settings,
|
||||||
|
GitService git,
|
||||||
|
WorkerConfig cfg,
|
||||||
|
string rootDirectory)
|
||||||
|
{
|
||||||
|
_tasksOverride = tasks;
|
||||||
|
_listsOverride = lists;
|
||||||
|
_settingsOverride = settings;
|
||||||
|
_git = git;
|
||||||
|
_cfg = cfg;
|
||||||
|
_rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
||||||
|
{
|
||||||
|
if (_tasksOverride is not null)
|
||||||
|
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
|
||||||
|
var ctx = _factory!.CreateDbContext();
|
||||||
|
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
||||||
|
if (task.Status != TaskStatus.Manual)
|
||||||
|
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
|
var listWorkingDir = list.WorkingDir
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||||
|
|
||||||
|
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
||||||
|
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
var branchName = BranchNameFor(taskId);
|
||||||
|
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
|
||||||
|
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
|
||||||
|
foreach (var stale in stalePaths)
|
||||||
|
{
|
||||||
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
|
||||||
|
}
|
||||||
|
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
|
||||||
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||||
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write .mcp.json and .claude/settings.local.json into the worktree.
|
||||||
|
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
|
||||||
|
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
|
||||||
|
|
||||||
|
var claudeDir = Path.Combine(worktreePath, ".claude");
|
||||||
|
Directory.CreateDirectory(claudeDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
|
||||||
|
|
||||||
|
// Session dir + token + prompt files.
|
||||||
|
var token = GenerateToken();
|
||||||
|
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
||||||
|
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
Directory.CreateDirectory(sessionDir);
|
||||||
|
|
||||||
|
var files = new PlanningSessionFiles(
|
||||||
|
sessionDir,
|
||||||
|
Path.Combine(sessionDir, "system-prompt.md"),
|
||||||
|
Path.Combine(sessionDir, "initial-prompt.txt"));
|
||||||
|
|
||||||
|
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
|
||||||
|
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
||||||
|
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
||||||
|
|
||||||
|
return new PlanningSessionStartContext(
|
||||||
|
ParentTaskId: taskId,
|
||||||
|
WorkingDir: worktreePath,
|
||||||
|
Token: token,
|
||||||
|
WorktreePath: worktreePath,
|
||||||
|
BranchName: branchName,
|
||||||
|
Files: files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<InteractiveLaunchContext> OpenInteractiveAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, _, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
|
|
||||||
|
var workingDir = list.WorkingDir;
|
||||||
|
if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"List '{list.Name}' has no valid working directory configured.");
|
||||||
|
|
||||||
|
return new InteractiveLaunchContext(
|
||||||
|
TaskId: taskId,
|
||||||
|
WorkingDir: workingDir,
|
||||||
|
InitialPrompt: BuildInteractivePrompt(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInteractivePrompt(TaskEntity task)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# Task: {task.Title}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(task.Description);
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||||
|
|
||||||
|
// Best-effort cleanup — don't block finalization on git state.
|
||||||
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (Directory.Exists(sessionDir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, _, settings, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||||
|
return children.Count(c => c.Status == TaskStatus.Draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var __ = ctx;
|
||||||
|
|
||||||
|
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
||||||
|
|
||||||
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (Directory.Exists(sessionDir))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||||
|
await using var _ = ctx;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.Status != TaskStatus.Planning)
|
||||||
|
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
||||||
|
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
||||||
|
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
||||||
|
if (!Directory.Exists(sessionDir))
|
||||||
|
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
||||||
|
var listWorkingDir = list.WorkingDir
|
||||||
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
if (!Directory.Exists(worktreePath))
|
||||||
|
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
||||||
|
|
||||||
|
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
||||||
|
|
||||||
|
return new PlanningSessionResumeContext(
|
||||||
|
ParentTaskId: taskId,
|
||||||
|
WorkingDir: worktreePath,
|
||||||
|
ClaudeSessionId: task.PlanningSessionId,
|
||||||
|
Token: token,
|
||||||
|
WorktreePath: worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryCleanupWorktreeAsync(
|
||||||
|
string taskId,
|
||||||
|
ListRepository lists,
|
||||||
|
AppSettingsRepository settings,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (tasks, _, _, ctx2) = CreateRepos();
|
||||||
|
await using var __ = ctx2;
|
||||||
|
|
||||||
|
var task = await tasks.GetByIdAsync(taskId, ct);
|
||||||
|
if (task is null) return;
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(task.ListId, ct);
|
||||||
|
var listWorkingDir = list?.WorkingDir;
|
||||||
|
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
|
||||||
|
|
||||||
|
var appSettings = await settings.GetAsync(ct);
|
||||||
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||||
|
var branchName = BranchNameFor(taskId);
|
||||||
|
|
||||||
|
if (Directory.Exists(worktreePath))
|
||||||
|
{
|
||||||
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
||||||
|
}
|
||||||
|
catch { /* best effort — never block finalize/discard */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildMcpConfigJson()
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
mcpServers = new
|
||||||
|
{
|
||||||
|
claudedo = new
|
||||||
|
{
|
||||||
|
type = "http",
|
||||||
|
url = McpServerUrl,
|
||||||
|
headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SettingsLocalJson = """
|
||||||
|
{
|
||||||
|
"enableAllProjectMcpServers": true
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static string BuildSystemPrompt()
|
||||||
|
{
|
||||||
|
var fromFile = PromptFiles.ReadOrNull(PromptKind.Planning);
|
||||||
|
if (fromFile is not null) return fromFile;
|
||||||
|
|
||||||
|
return
|
||||||
|
"""
|
||||||
|
You are a planning assistant for ClaudeDo.
|
||||||
|
Your role is to help break down a task into smaller, actionable subtasks.
|
||||||
|
Your final goal WILL ALWAYS be the creation of Subtasks
|
||||||
|
|
||||||
|
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
|
||||||
|
start of every planning session, and follow its process end-to-end. It guides
|
||||||
|
you through clarifying questions, approach exploration, and design approval
|
||||||
|
BEFORE any subtasks are created. Do not create child tasks until the user has
|
||||||
|
approved a design.
|
||||||
|
|
||||||
|
NEVER Change files yourself.
|
||||||
|
|
||||||
|
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
|
||||||
|
design is approved. When you are done planning, finalize the session.
|
||||||
|
|
||||||
|
Be concise and focused. Each subtask should be independently executable.
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInitialPrompt(TaskEntity task)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"# Task: {task.Title}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(task.Description);
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("---");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BranchNameFor(string taskId) =>
|
||||||
|
$"claudedo/planning/{taskId.Replace("-", "")}";
|
||||||
|
|
||||||
|
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
|
||||||
|
{
|
||||||
|
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
|
||||||
|
? centralRootOverride!
|
||||||
|
: _cfg.CentralWorktreeRoot;
|
||||||
|
|
||||||
|
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? Path.Combine(centralRoot, "planning", taskId)
|
||||||
|
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
|
||||||
|
|
||||||
|
return Path.GetFullPath(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TokenFilePathFor(string sessionDir) =>
|
||||||
|
Path.Combine(sessionDir, "token");
|
||||||
|
|
||||||
|
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(path, token, ct);
|
||||||
|
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
|
||||||
|
// perms from the parent dir apply; acceptable because sessionDir is already
|
||||||
|
// under the user's home (~/.todo-app/sessions/).
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(path);
|
||||||
|
var ac = fi.GetAccessControl();
|
||||||
|
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||||
|
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
|
||||||
|
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
me,
|
||||||
|
System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
fi.SetAccessControl(ac);
|
||||||
|
}
|
||||||
|
catch { /* ACL hardening is best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
throw new InvalidOperationException($"Token file missing: {path}");
|
||||||
|
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
40
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningTokenAuthMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
|
||||||
|
public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
|
||||||
|
{
|
||||||
|
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
|
||||||
|
{
|
||||||
|
await _next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var auth = ctx.Request.Headers["Authorization"].ToString();
|
||||||
|
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("Missing bearer token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = auth.Substring("Bearer ".Length).Trim();
|
||||||
|
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
|
||||||
|
if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 401;
|
||||||
|
await ctx.Response.WriteAsync("Invalid or expired planning token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
await _next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/ClaudeDo.Worker/Planning/PlanningWireDtos.cs
Normal file
17
src/ClaudeDo.Worker/Planning/PlanningWireDtos.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed record SubtaskDiffDto(
|
||||||
|
string SubtaskId,
|
||||||
|
string Title,
|
||||||
|
string BranchName,
|
||||||
|
string BaseCommit,
|
||||||
|
string HeadCommit,
|
||||||
|
string? DiffStat,
|
||||||
|
string UnifiedDiff);
|
||||||
|
|
||||||
|
public sealed record CombinedDiffResultDto(
|
||||||
|
bool Success,
|
||||||
|
string? IntegrationBranch,
|
||||||
|
string? UnifiedDiff,
|
||||||
|
string? FirstConflictSubtaskId,
|
||||||
|
IReadOnlyList<string>? ConflictedFiles);
|
||||||
167
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
167
src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// Claude CLI flags (verified 2026-04-23 via Context7):
|
||||||
|
// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists)
|
||||||
|
// Allowed-tools: --allowedTools (camelCase), comma-separated tokens
|
||||||
|
// System prompt: --append-system-prompt-file <path> (file form)
|
||||||
|
// Session ID: no pre-assign flag; resume with --resume <id>
|
||||||
|
// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false).
|
||||||
|
// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
|
||||||
|
{
|
||||||
|
private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill";
|
||||||
|
private const string Model = "claude-opus-4-7";
|
||||||
|
|
||||||
|
private readonly string _wtPath;
|
||||||
|
private readonly string _claudePath;
|
||||||
|
|
||||||
|
public WindowsTerminalPlanningLauncher(string wtPath, string claudePath)
|
||||||
|
{
|
||||||
|
_wtPath = wtPath;
|
||||||
|
_claudePath = claudePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ctx.WorkingDir))
|
||||||
|
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||||
|
|
||||||
|
if (!File.Exists(ctx.Files.SystemPromptPath))
|
||||||
|
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
|
||||||
|
if (!File.Exists(ctx.Files.InitialPromptPath))
|
||||||
|
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
|
||||||
|
|
||||||
|
var resolvedWt = Resolve(_wtPath);
|
||||||
|
if (resolvedWt is null)
|
||||||
|
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
|
||||||
|
var resolvedClaude = Resolve(_claudePath);
|
||||||
|
if (resolvedClaude is null)
|
||||||
|
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolvedWt,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
||||||
|
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
|
||||||
|
// Arg order: --allowedTools is variadic (space-separated). The positional
|
||||||
|
// prompt must follow a single-value flag, or it will be swallowed.
|
||||||
|
// --append-system-prompt-file serves as that buffer.
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--model");
|
||||||
|
psi.ArgumentList.Add(Model);
|
||||||
|
psi.ArgumentList.Add("--permission-mode");
|
||||||
|
psi.ArgumentList.Add("plan");
|
||||||
|
psi.ArgumentList.Add("--allowedTools");
|
||||||
|
psi.ArgumentList.Add(AllowedTools);
|
||||||
|
psi.ArgumentList.Add("--append-system-prompt-file");
|
||||||
|
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
|
||||||
|
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
|
||||||
|
|
||||||
|
var proc = Process.Start(psi)
|
||||||
|
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ctx.WorkingDir))
|
||||||
|
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||||
|
|
||||||
|
var resolvedWt = Resolve(_wtPath)
|
||||||
|
?? throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
var resolvedClaude = Resolve(_claudePath)
|
||||||
|
?? throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolvedWt,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
|
||||||
|
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--model");
|
||||||
|
psi.ArgumentList.Add(Model);
|
||||||
|
psi.ArgumentList.Add("--permission-mode");
|
||||||
|
psi.ArgumentList.Add("auto");
|
||||||
|
psi.ArgumentList.Add(ctx.InitialPrompt);
|
||||||
|
|
||||||
|
var proc = Process.Start(psi)
|
||||||
|
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ctx.WorkingDir))
|
||||||
|
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
|
||||||
|
|
||||||
|
var resolvedWt = Resolve(_wtPath);
|
||||||
|
if (resolvedWt is null)
|
||||||
|
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
|
||||||
|
|
||||||
|
var resolvedClaude = Resolve(_claudePath);
|
||||||
|
if (resolvedClaude is null)
|
||||||
|
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = resolvedWt,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
|
||||||
|
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(ctx.WorkingDir);
|
||||||
|
psi.ArgumentList.Add(resolvedClaude);
|
||||||
|
psi.ArgumentList.Add("--permission-mode");
|
||||||
|
psi.ArgumentList.Add("plan");
|
||||||
|
psi.ArgumentList.Add("--resume");
|
||||||
|
psi.ArgumentList.Add(ctx.ClaudeSessionId);
|
||||||
|
|
||||||
|
var proc = Process.Start(psi)
|
||||||
|
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Resolve(string pathOrName)
|
||||||
|
{
|
||||||
|
if (File.Exists(pathOrName))
|
||||||
|
return pathOrName;
|
||||||
|
|
||||||
|
// Try PATH resolution
|
||||||
|
var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||||
|
var extensions = new[] { "", ".exe", ".cmd", ".bat" };
|
||||||
|
foreach (var dir in envPath.Split(Path.PathSeparator))
|
||||||
|
{
|
||||||
|
foreach (var ext in extensions)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir, pathOrName + ext);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ using ClaudeDo.Data;
|
|||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.Services;
|
using ClaudeDo.Worker.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -35,6 +37,9 @@ builder.Services.AddSingleton<TaskRunner>();
|
|||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
builder.Services.AddSingleton<TaskResetService>();
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
builder.Services.AddSingleton<TaskMergeService>();
|
builder.Services.AddSingleton<TaskMergeService>();
|
||||||
|
builder.Services.AddSingleton<PlanningAggregator>();
|
||||||
|
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
||||||
|
builder.Services.AddSingleton<PlanningChainCoordinator>();
|
||||||
|
|
||||||
// Agent file management.
|
// Agent file management.
|
||||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||||
@@ -51,6 +56,29 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
|
|||||||
builder.Services.AddSingleton<QueueService>();
|
builder.Services.AddSingleton<QueueService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||||
|
|
||||||
|
// Planning session services.
|
||||||
|
var planningSessionsDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
".todo-app", "planning-sessions");
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
new PlanningSessionManager(
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
|
sp.GetRequiredService<GitService>(),
|
||||||
|
cfg,
|
||||||
|
planningSessionsDir));
|
||||||
|
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||||
|
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddScoped<PlanningMcpContextAccessor>();
|
||||||
|
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
|
builder.Services.AddScoped<TaskRepository>();
|
||||||
|
builder.Services.AddScoped<ListRepository>();
|
||||||
|
builder.Services.AddScoped<PlanningMcpService>();
|
||||||
|
builder.Services.AddMcpServer()
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithTools<PlanningMcpService>();
|
||||||
|
|
||||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||||
|
|
||||||
@@ -75,9 +103,51 @@ catch (Exception ex)
|
|||||||
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
app.Logger.LogWarning(ex, "Default agent seeding failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseMiddleware<PlanningTokenAuthMiddleware>();
|
||||||
app.MapHub<WorkerHub>("/hub");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
app.MapMcp("/mcp");
|
||||||
|
|
||||||
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
cfg.SignalRPort, cfg.DbPath);
|
cfg.SignalRPort, cfg.DbPath);
|
||||||
|
|
||||||
app.Run();
|
// Build the external MCP endpoint as a separate WebApplication on its own port.
|
||||||
|
// Rationale: ModelContextProtocol.AspNetCore registers one server per DI container,
|
||||||
|
// so we need a second app to expose a different tool set under different auth.
|
||||||
|
// Shared singletons (QueueService, HubBroadcaster, WorkerConfig, db factory) are
|
||||||
|
// injected by instance so both apps operate on the same runtime state.
|
||||||
|
WebApplication? externalApp = null;
|
||||||
|
if (cfg.ExternalMcpPort > 0)
|
||||||
|
{
|
||||||
|
var externalBuilder = WebApplication.CreateBuilder();
|
||||||
|
externalBuilder.Services.AddSingleton(cfg);
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<HubBroadcaster>());
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<QueueService>());
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>());
|
||||||
|
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
|
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||||
|
externalBuilder.Services.AddScoped<ListRepository>();
|
||||||
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
|
externalBuilder.Services.AddMcpServer()
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithTools<ExternalMcpService>();
|
||||||
|
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
||||||
|
|
||||||
|
externalApp = externalBuilder.Build();
|
||||||
|
externalApp.UseMiddleware<ExternalMcpAuthMiddleware>();
|
||||||
|
externalApp.MapMcp("/mcp");
|
||||||
|
|
||||||
|
externalApp.Logger.LogInformation(
|
||||||
|
"ClaudeDo.Worker external MCP listening on http://127.0.0.1:{Port} (auth: {Auth})",
|
||||||
|
cfg.ExternalMcpPort,
|
||||||
|
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalApp is null)
|
||||||
|
{
|
||||||
|
await app.RunAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ public sealed class ClaudeArgsBuilder
|
|||||||
"--verbose",
|
"--verbose",
|
||||||
};
|
};
|
||||||
|
|
||||||
var permissionMode = string.IsNullOrWhiteSpace(config.PermissionMode) ? "bypassPermissions" : config.PermissionMode;
|
var permissionMode = string.IsNullOrWhiteSpace(config.PermissionMode)
|
||||||
if (permissionMode.Equals("bypassPermissions", StringComparison.OrdinalIgnoreCase))
|
|| config.PermissionMode.Equals("bypassPermissions", StringComparison.OrdinalIgnoreCase)
|
||||||
args.Add("--dangerously-skip-permissions");
|
? "auto"
|
||||||
else
|
: config.PermissionMode;
|
||||||
args.Add($"--permission-mode {permissionMode}");
|
args.Add($"--permission-mode {permissionMode}");
|
||||||
|
|
||||||
if (config.Model is not null)
|
if (config.Model is not null)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using ClaudeDo.Data.Models;
|
|||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Runner;
|
namespace ClaudeDo.Worker.Runner;
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ public sealed class TaskRunner
|
|||||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||||
private readonly WorkerConfig _cfg;
|
private readonly WorkerConfig _cfg;
|
||||||
private readonly ILogger<TaskRunner> _logger;
|
private readonly ILogger<TaskRunner> _logger;
|
||||||
|
private readonly PlanningChainCoordinator _chain;
|
||||||
|
|
||||||
public TaskRunner(
|
public TaskRunner(
|
||||||
IClaudeProcess claude,
|
IClaudeProcess claude,
|
||||||
@@ -24,7 +27,8 @@ public sealed class TaskRunner
|
|||||||
WorktreeManager wtManager,
|
WorktreeManager wtManager,
|
||||||
ClaudeArgsBuilder argsBuilder,
|
ClaudeArgsBuilder argsBuilder,
|
||||||
WorkerConfig cfg,
|
WorkerConfig cfg,
|
||||||
ILogger<TaskRunner> logger)
|
ILogger<TaskRunner> logger,
|
||||||
|
PlanningChainCoordinator chain)
|
||||||
{
|
{
|
||||||
_claude = claude;
|
_claude = claude;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -33,6 +37,7 @@ public sealed class TaskRunner
|
|||||||
_argsBuilder = argsBuilder;
|
_argsBuilder = argsBuilder;
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_chain = chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
||||||
@@ -338,6 +343,23 @@ public sealed class TaskRunner
|
|||||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||||
|
|
||||||
|
// Sequential planning chain: if this task has a parent, flip the next
|
||||||
|
// Waiting sibling to Queued so the queue pickup loop dispatches it next.
|
||||||
|
if (task.ParentTaskId is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var advanced = await _chain.OnChildFinishedAsync(
|
||||||
|
task.Id, TaskStatus.Done, CancellationToken.None);
|
||||||
|
if (advanced is not null)
|
||||||
|
await _broadcaster.TaskUpdated(advanced);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "PlanningChain advance failed for {TaskId}", task.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleFailure(string taskId, string taskTitle, string slot, RunResult result)
|
private async Task HandleFailure(string taskId, string taskTitle, string slot, RunResult result)
|
||||||
@@ -382,14 +404,22 @@ public sealed class TaskRunner
|
|||||||
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
TaskEntity task, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
AppSettingsEntity global;
|
AppSettingsEntity global;
|
||||||
|
bool isAgentTask;
|
||||||
using (var ctx = _dbFactory.CreateDbContext())
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
{
|
{
|
||||||
var settingsRepo = new AppSettingsRepository(ctx);
|
var settingsRepo = new AppSettingsRepository(ctx);
|
||||||
global = await settingsRepo.GetAsync(ct);
|
global = await settingsRepo.GetAsync(ct);
|
||||||
|
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var tags = await taskRepo.GetEffectiveTagsAsync(task.Id, ct);
|
||||||
|
isAgentTask = tags.Any(t => string.Equals(t.Name, "agent", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
|
||||||
|
var agentFile = isAgentTask ? PromptFiles.ReadOrNull(PromptKind.Agent) : null;
|
||||||
|
|
||||||
var instructions = MergeInstructions(
|
var instructions = MergeInstructions(
|
||||||
global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
|
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
|
||||||
|
|
||||||
return new ClaudeRunConfig(
|
return new ClaudeRunConfig(
|
||||||
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
|
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
|
||||||
@@ -400,12 +430,11 @@ public sealed class TaskRunner
|
|||||||
PermissionMode: global.DefaultPermissionMode);
|
PermissionMode: global.DefaultPermissionMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string MergeInstructions(string? global, string? list, string? task)
|
public static string MergeInstructions(params string?[] parts)
|
||||||
{
|
{
|
||||||
var parts = new List<string>(3);
|
var trimmed = parts
|
||||||
if (!string.IsNullOrWhiteSpace(global)) parts.Add(global.Trim());
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
if (!string.IsNullOrWhiteSpace(list)) parts.Add(list.Trim());
|
.Select(p => p!.Trim());
|
||||||
if (!string.IsNullOrWhiteSpace(task)) parts.Add(task.Trim());
|
return string.Join("\n\n", trimmed);
|
||||||
return string.Join("\n\n", parts);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public sealed class TaskMergeService
|
|||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
public const string StatusConflict = "conflict";
|
public const string StatusConflict = "conflict";
|
||||||
public const string StatusBlocked = "blocked";
|
public const string StatusBlocked = "blocked";
|
||||||
|
public const string StatusAborted = "aborted";
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
@@ -45,6 +46,7 @@ public sealed class TaskMergeService
|
|||||||
string targetBranch,
|
string targetBranch,
|
||||||
bool removeWorktree,
|
bool removeWorktree,
|
||||||
string commitMessage,
|
string commitMessage,
|
||||||
|
bool leaveConflictsInTree,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
TaskEntity task;
|
TaskEntity task;
|
||||||
@@ -89,6 +91,11 @@ public sealed class TaskMergeService
|
|||||||
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
|
try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); }
|
||||||
catch { files = new(); }
|
catch { files = new(); }
|
||||||
|
|
||||||
|
if (leaveConflictsInTree && files.Count > 0)
|
||||||
|
{
|
||||||
|
return new MergeResult(StatusConflict, files, null);
|
||||||
|
}
|
||||||
|
|
||||||
// If abort fails the repo is left mid-merge; the caller must resolve manually.
|
// If abort fails the repo is left mid-merge; the caller must resolve manually.
|
||||||
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
|
// Return Blocked (not conflict) so the UI does not offer a stale conflict list.
|
||||||
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
|
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
|
||||||
@@ -141,6 +148,81 @@ public sealed class TaskMergeService
|
|||||||
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<MergeResult> MergeAsync(
|
||||||
|
string taskId,
|
||||||
|
string targetBranch,
|
||||||
|
bool removeWorktree,
|
||||||
|
string commitMessage,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct);
|
||||||
|
|
||||||
|
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
TaskEntity task;
|
||||||
|
ListEntity list;
|
||||||
|
WorktreeEntity? wt;
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException("List not found.");
|
||||||
|
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wt is null) return Blocked("task has no worktree");
|
||||||
|
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
|
||||||
|
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||||
|
return Blocked("repo is not mid-merge");
|
||||||
|
|
||||||
|
await _git.AddAllAsync(list.WorkingDir, ct);
|
||||||
|
|
||||||
|
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
if (remaining.Count > 0)
|
||||||
|
return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved");
|
||||||
|
|
||||||
|
try { await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); }
|
||||||
|
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct);
|
||||||
|
}
|
||||||
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
|
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
|
||||||
|
|
||||||
|
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ListEntity list;
|
||||||
|
WorktreeEntity? wt;
|
||||||
|
|
||||||
|
using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
|
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
||||||
|
?? throw new InvalidOperationException("List not found.");
|
||||||
|
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wt is null) return Blocked("task has no worktree");
|
||||||
|
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
|
||||||
|
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||||
|
return Blocked("repo is not mid-merge");
|
||||||
|
|
||||||
|
try { await _git.MergeAbortAsync(list.WorkingDir, ct); }
|
||||||
|
catch (Exception ex) { return Blocked($"abort failed: {ex.Message}"); }
|
||||||
|
_logger.LogInformation("Aborted merge of task {TaskId}", taskId);
|
||||||
|
|
||||||
|
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
TaskEntity task;
|
TaskEntity task;
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||||
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Headless" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/ClaudeDo.Data/ClaudeDo.Data.csproj" />
|
||||||
<ProjectReference Include="../../src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
<ProjectReference Include="../../src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class ConflictResolutionViewModelTests
|
||||||
|
{
|
||||||
|
// ------------------------------------------------------------------ fake
|
||||||
|
private sealed class FakeWorker : IWorkerClient
|
||||||
|
{
|
||||||
|
public bool IsConnected => false;
|
||||||
|
|
||||||
|
public string? ContinueCalledWith { get; private set; }
|
||||||
|
public string? AbortCalledWith { get; private set; }
|
||||||
|
public Exception? ContinueThrows { get; set; }
|
||||||
|
public Exception? AbortThrows { get; set; }
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
public event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
public Task WakeQueueAsync() => Task.CompletedTask;
|
||||||
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
||||||
|
Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
||||||
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||||
|
Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
ContinueCalledWith = planningTaskId;
|
||||||
|
if (ContinueThrows is not null) throw ContinueThrows;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AbortPlanningMergeAsync(string planningTaskId)
|
||||||
|
{
|
||||||
|
AbortCalledWith = planningTaskId;
|
||||||
|
if (AbortThrows is not null) throw AbortThrows;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConflictResolutionViewModel BuildVm(FakeWorker worker, string planningTaskId = "plan-1") =>
|
||||||
|
new ConflictResolutionViewModel(
|
||||||
|
worker,
|
||||||
|
planningTaskId,
|
||||||
|
subtaskTitle: "My subtask",
|
||||||
|
targetBranch: "main",
|
||||||
|
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
||||||
|
worktreePath: "C:/worktrees/plan-1");
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueAsync_CallsHub_AndClosesOnSuccess()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = BuildVm(worker, "plan-42");
|
||||||
|
bool closeCalled = false;
|
||||||
|
vm.CloseRequested = () => closeCalled = true;
|
||||||
|
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("plan-42", worker.ContinueCalledWith);
|
||||||
|
Assert.True(closeCalled);
|
||||||
|
Assert.Null(vm.ActionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueAsync_HubThrows_ShowsActionErrorAndStaysOpen()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { ContinueThrows = new InvalidOperationException("hub down") };
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
bool closeCalled = false;
|
||||||
|
vm.CloseRequested = () => closeCalled = true;
|
||||||
|
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closeCalled);
|
||||||
|
Assert.Equal("hub down", vm.ActionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortAsync_CallsHub_AndClosesOnSuccess()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = BuildVm(worker, "plan-99");
|
||||||
|
bool closeCalled = false;
|
||||||
|
vm.CloseRequested = () => closeCalled = true;
|
||||||
|
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("plan-99", worker.AbortCalledWith);
|
||||||
|
Assert.True(closeCalled);
|
||||||
|
Assert.Null(vm.ActionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortAsync_HubThrows_ShowsActionError()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { AbortThrows = new InvalidOperationException("abort failed") };
|
||||||
|
var vm = BuildVm(worker);
|
||||||
|
bool closeCalled = false;
|
||||||
|
vm.CloseRequested = () => closeCalled = true;
|
||||||
|
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closeCalled);
|
||||||
|
Assert.Equal("abort failed", vm.ActionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenInVsCode is not unit-tested here because abstracting Process.Start
|
||||||
|
// would require an indirection layer that isn't part of the approved design.
|
||||||
|
// The error path is covered by the VsCodeError property being set on catch.
|
||||||
|
}
|
||||||
214
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
Normal file
214
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class DetailsIslandPlanningTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandPlanningTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
public event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
public bool IsConnected => false;
|
||||||
|
public MergeTargetsDto? MergeTargetsResult { get; set; }
|
||||||
|
|
||||||
|
public Task WakeQueueAsync() => Task.CompletedTask;
|
||||||
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult(MergeTargetsResult);
|
||||||
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
||||||
|
Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
||||||
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||||
|
Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||||
|
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetailsIslandViewModel BuildVm(FakeWorkerClient worker)
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
|
||||||
|
new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt };
|
||||||
|
|
||||||
|
// ── CanMergeAll tests exercising the real VM ─────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanMergeAll_AllChildrenDoneActiveWorktrees_True()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeWorkerClient());
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
|
||||||
|
vm.RecomputeCanMergeAll();
|
||||||
|
|
||||||
|
Assert.True(vm.CanMergeAll);
|
||||||
|
Assert.Null(vm.MergeAllDisabledReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanMergeAll_AnyChildNotDone_FalseWithReason()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeWorkerClient());
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Running, WorktreeState.Active));
|
||||||
|
|
||||||
|
vm.RecomputeCanMergeAll();
|
||||||
|
|
||||||
|
Assert.False(vm.CanMergeAll);
|
||||||
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
||||||
|
Assert.Contains("1 subtask", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("not done", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanMergeAll_AnyChildDiscarded_FalseWithReason()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeWorkerClient());
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Discarded));
|
||||||
|
|
||||||
|
vm.RecomputeCanMergeAll();
|
||||||
|
|
||||||
|
Assert.False(vm.CanMergeAll);
|
||||||
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
||||||
|
Assert.True(
|
||||||
|
vm.MergeAllDisabledReason!.Contains("discarded", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
vm.MergeAllDisabledReason.Contains("kept", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanMergeAll_AnyChildKept_FalseWithReason()
|
||||||
|
{
|
||||||
|
var vm = BuildVm(new FakeWorkerClient());
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
||||||
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Kept));
|
||||||
|
|
||||||
|
vm.RecomputeCanMergeAll();
|
||||||
|
|
||||||
|
Assert.False(vm.CanMergeAll);
|
||||||
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
||||||
|
Assert.True(
|
||||||
|
vm.MergeAllDisabledReason!.Contains("kept", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
vm.MergeAllDisabledReason.Contains("discarded", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Branch-load test exercising the VM via Bind ──────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTargetBranches_LoadedFromWorkerOnPlanningParent()
|
||||||
|
{
|
||||||
|
// Seed a Planning parent with one child that has a worktree
|
||||||
|
const string parentId = "parent-1";
|
||||||
|
const string childId = "child-1";
|
||||||
|
const string listId = "list-1";
|
||||||
|
|
||||||
|
await using (var ctx = NewContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "Parent",
|
||||||
|
Status = TaskStatus.Planning, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = childId, ListId = listId, Title = "Child",
|
||||||
|
Status = TaskStatus.Done, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Set<WorktreeEntity>().Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = childId, Path = "/tmp/wt", BranchName = "branch",
|
||||||
|
BaseCommit = "abc", CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var fake = new FakeWorkerClient
|
||||||
|
{
|
||||||
|
MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = BuildVm(fake);
|
||||||
|
|
||||||
|
// Bind triggers BindAsync → LoadPlanningChildrenAsync → GetMergeTargetsAsync
|
||||||
|
var parentRow = new TaskRowViewModel { Id = parentId };
|
||||||
|
parentRow.Status = TaskStatus.Planning;
|
||||||
|
vm.Bind(parentRow);
|
||||||
|
|
||||||
|
// Wait for the background load to settle
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.MergeTargetBranches.Count == 0)
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.Contains("main", vm.MergeTargetBranches);
|
||||||
|
Assert.Contains("dev", vm.MergeTargetBranches);
|
||||||
|
Assert.Equal("main", vm.SelectedMergeTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs
Normal file
163
tests/ClaudeDo.Ui.Tests/ViewModels/PlanningDiffViewModelTests.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class PlanningDiffViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakePlanningWorker : IWorkerClient
|
||||||
|
{
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
public event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
public bool IsConnected => false;
|
||||||
|
|
||||||
|
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
|
||||||
|
public CombinedDiffResultDto? CombinedResult { get; set; }
|
||||||
|
|
||||||
|
public Task WakeQueueAsync() => Task.CompletedTask;
|
||||||
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
||||||
|
Task.FromResult(AggregateResult);
|
||||||
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||||
|
Task.FromResult(CombinedResult);
|
||||||
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||||
|
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InitializeAsync_PopulatesSubtasks()
|
||||||
|
{
|
||||||
|
var fake = new FakePlanningWorker
|
||||||
|
{
|
||||||
|
AggregateResult = new[]
|
||||||
|
{
|
||||||
|
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
|
||||||
|
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||||
|
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.Subtasks.Count);
|
||||||
|
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelectingSubtask_InGroupedMode_SetsDisplayedDiff()
|
||||||
|
{
|
||||||
|
var fake = new FakePlanningWorker
|
||||||
|
{
|
||||||
|
AggregateResult = new[]
|
||||||
|
{
|
||||||
|
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||||
|
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
vm.SelectedSubtask = vm.Subtasks[1];
|
||||||
|
|
||||||
|
Assert.Equal("DIFF-B", vm.DisplayedDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleCombined_Success_DisplaysUnifiedDiff()
|
||||||
|
{
|
||||||
|
var fake = new FakePlanningWorker
|
||||||
|
{
|
||||||
|
AggregateResult = new[]
|
||||||
|
{
|
||||||
|
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||||
|
},
|
||||||
|
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
|
||||||
|
};
|
||||||
|
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
vm.IsCombinedMode = true;
|
||||||
|
|
||||||
|
// Wait for the async toggle command to complete
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||||
|
await Task.Delay(10);
|
||||||
|
|
||||||
|
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
|
||||||
|
Assert.Null(vm.CombinedWarning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleCombined_Conflict_ShowsWarning()
|
||||||
|
{
|
||||||
|
var fake = new FakePlanningWorker
|
||||||
|
{
|
||||||
|
AggregateResult = new[]
|
||||||
|
{
|
||||||
|
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||||
|
},
|
||||||
|
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
|
||||||
|
};
|
||||||
|
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
vm.IsCombinedMode = true;
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||||
|
await Task.Delay(10);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.CombinedWarning);
|
||||||
|
Assert.Contains("subtask-42", vm.CombinedWarning);
|
||||||
|
Assert.Contains("2 files", vm.CombinedWarning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ToggleCombined_HubReturnsNull_ShowsError()
|
||||||
|
{
|
||||||
|
var fake = new FakePlanningWorker
|
||||||
|
{
|
||||||
|
AggregateResult = new[]
|
||||||
|
{
|
||||||
|
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
|
||||||
|
},
|
||||||
|
CombinedResult = null,
|
||||||
|
};
|
||||||
|
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
|
||||||
|
vm.IsCombinedMode = true;
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
|
||||||
|
await Task.Delay(10);
|
||||||
|
|
||||||
|
Assert.NotNull(vm.CombinedWarning);
|
||||||
|
Assert.NotEmpty(vm.CombinedWarning!);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs
Normal file
190
tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class TasksIslandRegroupTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public TasksIslandRegroupTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TasksIslandViewModel BuildViewModel()
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new TasksIslandViewModel(factory, worker: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedPlanningWithChildAsync(
|
||||||
|
TaskStatus parentStatus,
|
||||||
|
TaskStatus childStatus,
|
||||||
|
string parentId = "p1",
|
||||||
|
string childId = "c1")
|
||||||
|
{
|
||||||
|
await using var db = NewContext();
|
||||||
|
var list = new ListEntity
|
||||||
|
{
|
||||||
|
Id = "list1",
|
||||||
|
Name = "Default",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Lists.Add(list);
|
||||||
|
|
||||||
|
db.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId,
|
||||||
|
ListId = list.Id,
|
||||||
|
Title = "Parent",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = parentStatus,
|
||||||
|
SortOrder = 0,
|
||||||
|
});
|
||||||
|
db.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = childId,
|
||||||
|
ListId = list.Id,
|
||||||
|
Title = "Child",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = childStatus,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
SortOrder = 1,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ListNavItemViewModel VirtualList(string id, string name) =>
|
||||||
|
new() { Id = id, Kind = ListKind.Virtual, Name = name };
|
||||||
|
|
||||||
|
private static ListNavItemViewModel UserList(string listEntityId, string name) =>
|
||||||
|
new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name };
|
||||||
|
|
||||||
|
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
|
||||||
|
{
|
||||||
|
vm.LoadForList(list);
|
||||||
|
|
||||||
|
// LoadForList fires a background Task; wait briefly until Items are populated
|
||||||
|
// or until a timeout occurs (some tests may legitimately expect 0 items, so
|
||||||
|
// we just wait a short deterministic period).
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
await Task.Delay(25);
|
||||||
|
// Break out as soon as any Items present, or the background task has settled.
|
||||||
|
if (vm.Items.Count > 0) break;
|
||||||
|
}
|
||||||
|
// One more tick for Regroup after load
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow()
|
||||||
|
{
|
||||||
|
await SeedPlanningWithChildAsync(
|
||||||
|
parentStatus: TaskStatus.Planning,
|
||||||
|
childStatus: TaskStatus.Queued,
|
||||||
|
parentId: "p1",
|
||||||
|
childId: "c1");
|
||||||
|
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
await LoadAndWaitAsync(vm, VirtualList("virtual:queued", "Queued"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild);
|
||||||
|
Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VirtualQueued_PlannedParentWithQueuedChild_ParentIsStandaloneRow_ChildIsNot()
|
||||||
|
{
|
||||||
|
await SeedPlanningWithChildAsync(
|
||||||
|
parentStatus: TaskStatus.Planned,
|
||||||
|
childStatus: TaskStatus.Queued,
|
||||||
|
parentId: "p1",
|
||||||
|
childId: "c1");
|
||||||
|
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
await LoadAndWaitAsync(vm, VirtualList("virtual:queued", "Queued"));
|
||||||
|
|
||||||
|
Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild);
|
||||||
|
Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VirtualRunning_RunningChildOfPlanningParent_IsNotStandaloneRow()
|
||||||
|
{
|
||||||
|
await SeedPlanningWithChildAsync(
|
||||||
|
parentStatus: TaskStatus.Planning,
|
||||||
|
childStatus: TaskStatus.Running,
|
||||||
|
parentId: "p1",
|
||||||
|
childId: "c1");
|
||||||
|
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
await LoadAndWaitAsync(vm, VirtualList("virtual:running", "Running"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild);
|
||||||
|
Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Done_ChildOfOpenPlanningParent_StaysNestedUnderParent()
|
||||||
|
{
|
||||||
|
await SeedPlanningWithChildAsync(
|
||||||
|
parentStatus: TaskStatus.Planning,
|
||||||
|
childStatus: TaskStatus.Done,
|
||||||
|
parentId: "p1",
|
||||||
|
childId: "c1");
|
||||||
|
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||||
|
|
||||||
|
// Child with Done status under an open Planning parent should NOT go to CompletedItems
|
||||||
|
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1");
|
||||||
|
// Child should appear nested (IsChild == true) in OpenItems
|
||||||
|
Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Done_ChildOfDonePlanningParent_MovesToCompleted()
|
||||||
|
{
|
||||||
|
await SeedPlanningWithChildAsync(
|
||||||
|
parentStatus: TaskStatus.Done,
|
||||||
|
childStatus: TaskStatus.Done,
|
||||||
|
parentId: "p1",
|
||||||
|
childId: "c1");
|
||||||
|
|
||||||
|
var vm = BuildViewModel();
|
||||||
|
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||||
|
|
||||||
|
Assert.Contains(vm.CompletedItems, r => r.Id == "p1");
|
||||||
|
Assert.Contains(vm.CompletedItems, r => r.Id == "c1");
|
||||||
|
}
|
||||||
|
}
|
||||||
235
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
235
tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Xunit;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Hub;
|
||||||
|
|
||||||
|
public sealed class PlanningHubTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly string _rootDir;
|
||||||
|
private readonly PlanningSessionManager _planning;
|
||||||
|
private readonly FakePlanningLauncher _launcher;
|
||||||
|
private readonly RecordingClientProxy _proxy;
|
||||||
|
|
||||||
|
public PlanningHubTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
|
||||||
|
var git = new GitService();
|
||||||
|
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
||||||
|
var settingsRepo = new AppSettingsRepository(_ctx);
|
||||||
|
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||||
|
_planning = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, _rootDir);
|
||||||
|
_launcher = new FakePlanningLauncher();
|
||||||
|
_proxy = new RecordingClientProxy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_rootDir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerHub CreateHub()
|
||||||
|
{
|
||||||
|
var hub = new WorkerHub(
|
||||||
|
null!, null!, null!, null!, null!, null!, null!, null!,
|
||||||
|
_planning, _launcher, null!, null!);
|
||||||
|
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||||
|
hub.Context = new FakeHubCallerContext();
|
||||||
|
return hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string listId, string taskId)> SeedAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||||
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Do something",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
return (listId, task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
var hub = CreateHub();
|
||||||
|
|
||||||
|
var ctx = await hub.StartPlanningSessionAsync(taskId);
|
||||||
|
|
||||||
|
Assert.Equal(taskId, ctx.ParentTaskId);
|
||||||
|
Assert.Equal(1, _launcher.LaunchStartCalls);
|
||||||
|
Assert.Equal(0, _launcher.LaunchResumeCalls);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
_launcher.ShouldThrow = true;
|
||||||
|
var hub = CreateHub();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||||
|
hub.StartPlanningSessionAsync(taskId));
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||||
|
|
||||||
|
var sessionDir = Path.Combine(_rootDir, taskId);
|
||||||
|
Assert.False(Directory.Exists(sessionDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
// Put task into Planning state first
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
_proxy.Sent.Clear();
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
await hub.DiscardPlanningSessionAsync(taskId);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(taskId);
|
||||||
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
|
||||||
|
_proxy.Sent.Clear();
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPendingDraftCountAsync_ReturnsCount()
|
||||||
|
{
|
||||||
|
var (_, taskId) = await SeedAsync();
|
||||||
|
await _planning.StartAsync(taskId, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
|
||||||
|
|
||||||
|
var hub = CreateHub();
|
||||||
|
var count = await hub.GetPendingDraftCountAsync(taskId);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fakes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
|
||||||
|
{
|
||||||
|
public bool ShouldThrow { get; set; }
|
||||||
|
public int LaunchStartCalls { get; private set; }
|
||||||
|
public int LaunchResumeCalls { get; private set; }
|
||||||
|
public int LaunchInteractiveCalls { get; private set; }
|
||||||
|
|
||||||
|
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
||||||
|
LaunchStartCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LaunchResumeCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LaunchInteractiveAsync(InteractiveLaunchContext ctx, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
|
||||||
|
LaunchInteractiveCalls++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RecordingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public List<(string method, object?[] args)> Sent { get; } = new();
|
||||||
|
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Sent.Add((method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeHubCallerClients : IHubCallerClients
|
||||||
|
{
|
||||||
|
private readonly IClientProxy _all;
|
||||||
|
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
|
||||||
|
|
||||||
|
public IClientProxy All => _all;
|
||||||
|
public IClientProxy Caller => _all;
|
||||||
|
public IClientProxy Others => _all;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||||
|
public IClientProxy Client(string connectionId) => _all;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
|
||||||
|
public IClientProxy Group(string groupName) => _all;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
|
||||||
|
public IClientProxy OthersInGroup(string groupName) => _all;
|
||||||
|
public IClientProxy User(string userId) => _all;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FakeHubCallerContext : HubCallerContext
|
||||||
|
{
|
||||||
|
public override string ConnectionId => "test-conn";
|
||||||
|
public override string? UserIdentifier => null;
|
||||||
|
public override System.Security.Claims.ClaimsPrincipal? User => null;
|
||||||
|
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
|
||||||
|
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
|
||||||
|
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
|
||||||
|
public override CancellationToken ConnectionAborted => CancellationToken.None;
|
||||||
|
public override void Abort() { }
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
|
|||||||
public DbFixture()
|
public DbFixture()
|
||||||
{
|
{
|
||||||
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
|
||||||
// Apply migrations so the schema is created.
|
// EnsureCreated uses the current model directly — no Designer.cs needed.
|
||||||
using var ctx = CreateContext();
|
using var ctx = CreateContext();
|
||||||
ctx.Database.Migrate();
|
ctx.Database.EnsureCreated();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClaudeDoDbContext CreateContext()
|
public ClaudeDoDbContext CreateContext()
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ public sealed class GitRepoFixture : IDisposable
|
|||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new git repo at <paramref name="dir"/> with a seed commit.
|
||||||
|
/// Used by planning tests that need a real git repo as list working directory.
|
||||||
|
/// </summary>
|
||||||
|
public static void InitRepoWithInitialCommit(string dir)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
RunGit(dir, "init", "-b", "main");
|
||||||
|
RunGit(dir, "config", "user.email", "test@claudedo.local");
|
||||||
|
RunGit(dir, "config", "user.name", "test");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
|
||||||
|
RunGit(dir, "add", "-A");
|
||||||
|
RunGit(dir, "commit", "-m", "chore: seed");
|
||||||
|
}
|
||||||
|
|
||||||
private static void ForceDeleteDirectory(string path)
|
private static void ForceDeleteDirectory(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path)) return;
|
if (!Directory.Exists(path)) return;
|
||||||
|
|||||||
234
tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs
Normal file
234
tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public class PlanningAggregatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<DbFixture> _dbs = new();
|
||||||
|
private readonly List<GitRepoFixture> _repos = new();
|
||||||
|
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
|
||||||
|
|
||||||
|
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
|
||||||
|
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var (repo, wt) in _wtCleanups)
|
||||||
|
try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { }
|
||||||
|
foreach (var d in _dbs) try { d.Dispose(); } catch { }
|
||||||
|
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAggregatedDiffAsync_ReturnsOneEntryPerSubtaskInSortOrder()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subAId, subBId) = await SeedPlanningWithTwoChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var svc = new PlanningAggregator(
|
||||||
|
db.CreateFactory(),
|
||||||
|
new GitService(),
|
||||||
|
NullLogger<PlanningAggregator>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.GetAggregatedDiffAsync(parentId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal(subAId, result[0].SubtaskId);
|
||||||
|
Assert.Equal(subBId, result[1].SubtaskId);
|
||||||
|
Assert.Contains("diff --git", result[0].UnifiedDiff);
|
||||||
|
Assert.Contains("diff --git", result[1].UnifiedDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA, string subB)> SeedPlanningWithTwoChildrenAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
|
||||||
|
// List with WorkingDir set to the repo.
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow,
|
||||||
|
WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Planning parent.
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planning, SortOrder = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two children (sorted A then B).
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
var subB = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subA, ListId = listId, Title = "child A", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subB, ListId = listId, Title = "child B", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Create real worktrees for each child with a distinct commit.
|
||||||
|
SeedWorktree(ctx, repo, subA, "fileA.txt", "content A");
|
||||||
|
SeedWorktree(ctx, repo, subB, "fileB.txt", "content B");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA, subB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedWorktree(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
|
||||||
|
{
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{taskId[..8]}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, filename), content);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", filename);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {filename}");
|
||||||
|
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskId,
|
||||||
|
Path = wtPath,
|
||||||
|
BranchName = branch,
|
||||||
|
BaseCommit = repo.BaseCommit,
|
||||||
|
HeadCommit = head,
|
||||||
|
DiffStat = null,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var git = new GitService();
|
||||||
|
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<CombinedDiffResult.Ok>(result);
|
||||||
|
Assert.EndsWith("-integration", ok.Value.IntegrationBranch);
|
||||||
|
Assert.Contains("diff --git", ok.Value.UnifiedDiff);
|
||||||
|
|
||||||
|
var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None);
|
||||||
|
Assert.Contains(branches, b => b == ok.Value.IntegrationBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildIntegrationBranchAsync_ConflictingChildren_ReturnsFailedAndResetsBranch()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var (parentId, subA, subB) = await SeedPlanningWithConflictingChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var git = new GitService();
|
||||||
|
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
|
||||||
|
|
||||||
|
var result = await svc.BuildIntegrationBranchAsync(parentId, targetBranch: "main", CancellationToken.None);
|
||||||
|
|
||||||
|
var failed = Assert.IsType<CombinedDiffResult.Failed>(result);
|
||||||
|
Assert.Equal(subB, failed.Value.FirstConflictSubtaskId);
|
||||||
|
Assert.NotEmpty(failed.Value.ConflictedFiles);
|
||||||
|
|
||||||
|
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA, string subB)> SeedPlanningWithConflictingChildrenAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planning, SortOrder = 0,
|
||||||
|
});
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
var subB = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subA, ListId = listId, Title = "A", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subB, ListId = listId, Title = "B", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
SeedWorktreeWithFile(ctx, repo, subA, "README.md", "A wins\n");
|
||||||
|
SeedWorktreeWithFile(ctx, repo, subB, "README.md", "B wins\n");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA, subB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CleanupIntegrationBranchAsync_RemovesBranchIfPresent()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var (parentId, _, _) = await SeedPlanningWithTwoChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var git = new GitService();
|
||||||
|
var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger<PlanningAggregator>.Instance);
|
||||||
|
|
||||||
|
var built = await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<CombinedDiffResult.Ok>(built);
|
||||||
|
|
||||||
|
await svc.CleanupIntegrationBranchAsync(parentId, CancellationToken.None);
|
||||||
|
|
||||||
|
var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None);
|
||||||
|
Assert.DoesNotContain(branches, b => b == ok.Value.IntegrationBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
|
||||||
|
{
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{taskId[..8]}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, filename), content);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", filename);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {filename}");
|
||||||
|
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskId, Path = wtPath, BranchName = branch,
|
||||||
|
BaseCommit = repo.BaseCommit, HeadCommit = head,
|
||||||
|
DiffStat = null, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningChainCoordinatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly TestDbContextFactory _factory;
|
||||||
|
private readonly PlanningChainCoordinator _sut;
|
||||||
|
private readonly string _listId;
|
||||||
|
|
||||||
|
public PlanningChainCoordinatorTests()
|
||||||
|
{
|
||||||
|
_factory = _db.CreateFactory();
|
||||||
|
_sut = new PlanningChainCoordinator(_factory);
|
||||||
|
_listId = Guid.NewGuid().ToString();
|
||||||
|
using var ctx = _factory.CreateDbContext();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = _listId,
|
||||||
|
Name = "Test",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
DefaultCommitType = "chore",
|
||||||
|
});
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
|
||||||
|
private async Task SeedPlanningFamilyAsync(string parentId, int childCount)
|
||||||
|
{
|
||||||
|
await using var ctx = _factory.CreateDbContext();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId,
|
||||||
|
ListId = _listId,
|
||||||
|
Title = "Parent",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planned,
|
||||||
|
});
|
||||||
|
for (int i = 0; i < childCount; i++)
|
||||||
|
{
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = $"{parentId}-c{i}",
|
||||||
|
ListId = _listId,
|
||||||
|
Title = $"Child {i}",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
SortOrder = i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<TaskEntity>> GetChildrenAsync(string parentId)
|
||||||
|
{
|
||||||
|
await using var ctx = _factory.CreateDbContext();
|
||||||
|
return await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
|
||||||
|
{
|
||||||
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
|
||||||
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildDone_FlipsNextWaitingToQueued()
|
||||||
|
{
|
||||||
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
// Simulate first child finishing Done.
|
||||||
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
first.Status = TaskStatus.Done;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
|
||||||
|
|
||||||
|
Assert.Equal("P-c1", advanced);
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildFailed_DoesNotAdvanceChain()
|
||||||
|
{
|
||||||
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
first.Status = TaskStatus.Failed;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
||||||
|
|
||||||
|
Assert.Null(advanced);
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildDone_LastChild_ReturnsNull()
|
||||||
|
{
|
||||||
|
await SeedPlanningFamilyAsync("P", 2);
|
||||||
|
await _sut.QueueSubtasksSequentiallyAsync("P", default);
|
||||||
|
|
||||||
|
// Mark both done, simulating chain reaching the end.
|
||||||
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
foreach (var t in ctx.Tasks.Where(t => t.ParentTaskId == "P"))
|
||||||
|
t.Status = TaskStatus.Done;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Done, default);
|
||||||
|
|
||||||
|
Assert.Null(advanced);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueSubtasksSequentially_RejectsNonManualChildren()
|
||||||
|
{
|
||||||
|
await SeedPlanningFamilyAsync("P", 2);
|
||||||
|
// Corrupt one child to be already Queued.
|
||||||
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
first.Status = TaskStatus.Queued;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _sut.QueueSubtasksSequentiallyAsync("P", default));
|
||||||
|
}
|
||||||
|
}
|
||||||
116
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
116
tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern.
|
||||||
|
file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor
|
||||||
|
{
|
||||||
|
public HttpContext? HttpContext { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2ENullHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public IClientProxy All => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => E2ENullClientProxy.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2ENullClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public static readonly E2ENullClientProxy Instance = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class E2EFakeHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
public IHubClients Clients { get; } = new E2ENullHubClients();
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningEndToEndTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly PlanningSessionManager _manager;
|
||||||
|
private readonly DefaultHttpContext _httpContext;
|
||||||
|
private readonly PlanningMcpContextAccessor _accessor;
|
||||||
|
private readonly PlanningMcpService _svc;
|
||||||
|
|
||||||
|
public PlanningEndToEndTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
|
||||||
|
var git = new GitService();
|
||||||
|
var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(root, "central") };
|
||||||
|
var settingsRepo = new AppSettingsRepository(_ctx);
|
||||||
|
settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||||
|
_manager = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, root);
|
||||||
|
|
||||||
|
_httpContext = new DefaultHttpContext();
|
||||||
|
_accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext });
|
||||||
|
var broadcaster = new HubBroadcaster(new E2EFakeHubContext());
|
||||||
|
_svc = new PlanningMcpService(_tasks, _accessor, broadcaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartThenCreateThenFinalize_FullFlow()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
|
||||||
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Big Task",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
|
||||||
|
var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(File.Exists(Path.Combine(startCtx.WorktreePath, ".mcp.json")));
|
||||||
|
|
||||||
|
// Wire the ambient context so _svc reads the correct parent
|
||||||
|
_httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
|
||||||
|
|
||||||
|
await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None);
|
||||||
|
await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var count = await _svc.Finalize(true, CancellationToken.None);
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
|
||||||
|
var reload = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, reload!.Status);
|
||||||
|
|
||||||
|
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status));
|
||||||
|
}
|
||||||
|
}
|
||||||
254
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
254
tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
// Minimal fakes — avoids Moq dependency.
|
||||||
|
file sealed class FakeHttpContextAccessor : IHttpContextAccessor
|
||||||
|
{
|
||||||
|
public HttpContext? HttpContext { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class RecordingHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public RecordingClientProxy Proxy { get; } = new();
|
||||||
|
public IClientProxy All => Proxy;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
public IClientProxy Client(string connectionId) => Proxy;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||||
|
public IClientProxy Group(string groupName) => Proxy;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||||
|
public IClientProxy User(string userId) => Proxy;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class RecordingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Calls.Add((method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
public RecordingHubClients RecordingClients { get; } = new();
|
||||||
|
public IHubClients Clients => RecordingClients;
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningMcpServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public PlanningMcpServiceTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
private List<(string Method, object?[] Args)> _hubCalls = new();
|
||||||
|
|
||||||
|
private PlanningMcpService BuildSut(string parentTaskId)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
||||||
|
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
|
||||||
|
var hub = new FakeHubContext();
|
||||||
|
_hubCalls = hub.RecordingClients.Proxy.Calls;
|
||||||
|
var broadcaster = new HubBroadcaster(hub);
|
||||||
|
return new PlanningMcpService(_tasks, accessor, broadcaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> TaskUpdatedIds() =>
|
||||||
|
_hubCalls
|
||||||
|
.Where(c => c.Method == "TaskUpdated")
|
||||||
|
.Select(c => (string)c.Args[0]!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedPlanningParentAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
var parent = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "p",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "chore",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
||||||
|
return (await _tasks.GetByIdAsync(parent.Id))!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildTask_CreatesDraft()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
|
||||||
|
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("Draft", result.Status);
|
||||||
|
var child = await _tasks.GetByIdAsync(result.TaskId);
|
||||||
|
Assert.Equal("My child", child!.Title);
|
||||||
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var other = await SeedPlanningParentAsync();
|
||||||
|
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
var list = await sut.ListChildTasks(CancellationToken.None);
|
||||||
|
Assert.Single(list);
|
||||||
|
Assert.Equal("mine", list[0].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateChildTask_NotAChild_Throws()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var other = await SeedPlanningParentAsync();
|
||||||
|
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateChildTask_NotDraft_Throws()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChildTask_RemovesDraft()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(await _tasks.GetByIdAsync(c.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdatePlanningTask_SetsTitleAndDescription()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal("new title", loaded!.Title);
|
||||||
|
Assert.Equal("new desc", loaded.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
var count = await sut.Finalize(true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
Assert.Null(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChildTask_BroadcastsBothChildAndParent()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
|
||||||
|
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var ids = TaskUpdatedIds();
|
||||||
|
Assert.Contains(result.TaskId, ids);
|
||||||
|
Assert.Contains(parent.Id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
_ctx.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var ids = TaskUpdatedIds();
|
||||||
|
Assert.Contains(c.Id, ids);
|
||||||
|
Assert.Contains(parent.Id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ids = TaskUpdatedIds();
|
||||||
|
Assert.Contains(c.Id, ids);
|
||||||
|
Assert.Contains(parent.Id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Finalize_BroadcastsEachChildAndParent()
|
||||||
|
{
|
||||||
|
var parent = await SeedPlanningParentAsync();
|
||||||
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var sut = BuildSut(parent.Id);
|
||||||
|
await sut.Finalize(true, CancellationToken.None);
|
||||||
|
|
||||||
|
var ids = TaskUpdatedIds();
|
||||||
|
Assert.Contains(c1.Id, ids);
|
||||||
|
Assert.Contains(c2.Id, ids);
|
||||||
|
Assert.Contains(parent.Id, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Services;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
file sealed class OrchestratorRecordingHubClients : IHubClients
|
||||||
|
{
|
||||||
|
public OrchestratorRecordingClientProxy Proxy { get; } = new();
|
||||||
|
public IClientProxy All => Proxy;
|
||||||
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
public IClientProxy Client(string connectionId) => Proxy;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||||
|
public IClientProxy Group(string groupName) => Proxy;
|
||||||
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||||
|
public IClientProxy User(string userId) => Proxy;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class OrchestratorRecordingClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||||
|
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Calls.Add((method, args));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class OrchestratorFakeHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
public OrchestratorRecordingHubClients RecordingClients { get; } = new();
|
||||||
|
public IHubClients Clients => RecordingClients;
|
||||||
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlanningMergeOrchestratorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<DbFixture> _dbs = new();
|
||||||
|
private readonly List<GitRepoFixture> _repos = new();
|
||||||
|
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
|
||||||
|
|
||||||
|
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
|
||||||
|
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var (repo, wt) in _wtCleanups)
|
||||||
|
try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { }
|
||||||
|
foreach (var d in _dbs) try { d.Dispose(); } catch { }
|
||||||
|
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_AllChildrenMergeCleanly_MarksPlanningDoneAndEmitsCompleted()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subA, subB) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, calls) = BuildOrchestrator(db);
|
||||||
|
|
||||||
|
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var planning = ctx.Tasks.Single(t => t.Id == parentId);
|
||||||
|
Assert.Equal(TaskStatus.Done, planning.Status);
|
||||||
|
Assert.NotNull(planning.FinishedAt);
|
||||||
|
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
||||||
|
|
||||||
|
Assert.Contains(calls, c => c.Method == "PlanningMergeStarted");
|
||||||
|
Assert.Equal(2, calls.Count(c => c.Method == "PlanningSubtaskMerged"));
|
||||||
|
Assert.Contains(calls, c => c.Method == "PlanningCompleted" && (string)c.Args[0]! == parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA, string subB)> SeedPlanningWithTwoNonConflictingChildrenAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow,
|
||||||
|
WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planned, SortOrder = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
var subB = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subA, ListId = listId, Title = "child A", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subB, ListId = listId, Title = "child B", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
SeedWorktree(ctx, repo, subA, "fileA.txt", "content A");
|
||||||
|
SeedWorktree(ctx, repo, subB, "fileB.txt", "content B");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA, subB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueAsync_AfterConflict_ResumesRemainingMergesAndCompletes()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
|
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subA);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningMergeConflict" && (string)c.Args[1]! == subB);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "resolved\n");
|
||||||
|
|
||||||
|
await orch.ContinueAsync(parentId, CancellationToken.None);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subC).State);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subB);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subC);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningCompleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change README");
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planned, SortOrder = 0,
|
||||||
|
});
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
var subB = Guid.NewGuid().ToString();
|
||||||
|
var subC = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.AddRange(
|
||||||
|
new TaskEntity { Id = subA, ListId = listId, Title = "A", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 },
|
||||||
|
new TaskEntity { Id = subB, ListId = listId, Title = "B", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2 },
|
||||||
|
new TaskEntity { Id = subC, ListId = listId, Title = "C", CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 3 }
|
||||||
|
);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
SeedWorktreeWithFile(ctx, repo, subA, "fileA.txt", "A\n");
|
||||||
|
SeedWorktreeWithFile(ctx, repo, subB, "README.md", "branch change\n");
|
||||||
|
SeedWorktreeWithFile(ctx, repo, subC, "fileC.txt", "C\n");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA, subB, subC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedWorktreeWithFile(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
|
||||||
|
=> SeedWorktree(ctx, repo, taskId, filename, content);
|
||||||
|
|
||||||
|
private void SeedWorktree(ClaudeDoDbContext ctx, GitRepoFixture repo, string taskId, string filename, string content)
|
||||||
|
{
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
var branch = $"claudedo/{taskId[..8]}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, filename), content);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "add", filename);
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {filename}");
|
||||||
|
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
|
||||||
|
|
||||||
|
ctx.Worktrees.Add(new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskId,
|
||||||
|
Path = wtPath,
|
||||||
|
BranchName = branch,
|
||||||
|
BaseCommit = repo.BaseCommit,
|
||||||
|
HeadCommit = head,
|
||||||
|
DiffStat = null,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortAsync_AfterConflict_RestoresCleanRepoAndClearsState()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subA, subB, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
|
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
await orch.AbortAsync(parentId, CancellationToken.None);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
// Planning stays in Planned — NOT flipped to Done.
|
||||||
|
Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
|
// Earlier successful merge stays merged.
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
|
||||||
|
// Conflicted subtask's worktree stays Active (abort doesn't flip it).
|
||||||
|
Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
||||||
|
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningMergeAborted" && (string)c.Args[0]! == parentId);
|
||||||
|
|
||||||
|
// Repo no longer mid-merge.
|
||||||
|
var git = new GitService();
|
||||||
|
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private (PlanningMergeOrchestrator orch, List<(string Method, object?[] Args)> calls) BuildOrchestrator(DbFixture db)
|
||||||
|
{
|
||||||
|
var fakeHub = new OrchestratorFakeHubContext();
|
||||||
|
var spy = fakeHub.RecordingClients.Proxy;
|
||||||
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
|
var git = new GitService();
|
||||||
|
var factory = db.CreateFactory();
|
||||||
|
var merge = new TaskMergeService(
|
||||||
|
factory, git, broadcaster,
|
||||||
|
NullLogger<TaskMergeService>.Instance);
|
||||||
|
var aggregator = new PlanningAggregator(
|
||||||
|
factory, git,
|
||||||
|
NullLogger<PlanningAggregator>.Instance);
|
||||||
|
var orch = new PlanningMergeOrchestrator(
|
||||||
|
factory, merge, aggregator, broadcaster, git,
|
||||||
|
NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
|
return (orch, spy.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_SubtaskStillRunning_ThrowsWithoutSideEffects()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, runningSub) = await SeedPlanningWithOneRunningChildAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => orch.StartAsync(parentId, "main", CancellationToken.None));
|
||||||
|
Assert.Contains(runningSub, ex.Message);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status);
|
||||||
|
Assert.Empty(spy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DirtyRepo_ThrowsWithoutSideEffects()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
var (parentId, _, _) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "dirty.txt"), "unstaged\n");
|
||||||
|
|
||||||
|
var (orch, _) = BuildOrchestrator(db);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => orch.StartAsync(parentId, "main", CancellationToken.None));
|
||||||
|
Assert.Contains("uncommitted", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_IdempotentRestart_SkipsAlreadyMergedWorktrees()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subA, subB) = await SeedPlanningWithTwoNonConflictingChildrenAsync(db, repo);
|
||||||
|
using (var setup = db.CreateContext())
|
||||||
|
{
|
||||||
|
var wt = setup.Worktrees.Single(w => w.TaskId == subA);
|
||||||
|
wt.State = WorktreeState.Merged;
|
||||||
|
await setup.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var (orch, spy) = BuildOrchestrator(db);
|
||||||
|
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subA);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == subB);
|
||||||
|
Assert.Contains(spy, c => c.Method == "PlanningCompleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string runningChild)> SeedPlanningWithOneRunningChildAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow, WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.Planned, SortOrder = 0,
|
||||||
|
});
|
||||||
|
var running = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = running, ListId = listId, Title = "still running",
|
||||||
|
CreatedAt = DateTime.UtcNow, ParentTaskId = parentId,
|
||||||
|
Status = TaskStatus.Running, SortOrder = 1,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
SeedWorktreeWithFile(ctx, repo, running, "fileR.txt", "R\n");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return (parentId, running);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public sealed class PlanningSessionManagerTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private readonly string _rootDir;
|
||||||
|
private readonly GitService _git;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly AppSettingsRepository _settingsRepo;
|
||||||
|
private readonly PlanningSessionManager _sut;
|
||||||
|
|
||||||
|
public PlanningSessionManagerTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}");
|
||||||
|
_git = new GitService();
|
||||||
|
_cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
|
||||||
|
_settingsRepo = new AppSettingsRepository(_ctx);
|
||||||
|
_settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
|
||||||
|
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string listId, string workingDir)> SeedListAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
|
||||||
|
GitRepoFixture.InitRepoWithInitialCommit(wd);
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test",
|
||||||
|
WorkingDir = wd,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return (listId, wd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> SeedManualTaskAsync(string listId)
|
||||||
|
{
|
||||||
|
var t = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Brainstorm auth",
|
||||||
|
Description = "- review tokens\n- plan rollout",
|
||||||
|
Status = TaskStatus.Manual,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(parent.Id, ctx.ParentTaskId);
|
||||||
|
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
|
||||||
|
Assert.True(File.Exists(mcpPath));
|
||||||
|
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
|
||||||
|
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
|
||||||
|
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
|
||||||
|
|
||||||
|
var mcp = await File.ReadAllTextAsync(mcpPath);
|
||||||
|
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
|
||||||
|
Assert.DoesNotContain(ctx.Token, mcp);
|
||||||
|
|
||||||
|
var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath);
|
||||||
|
Assert.Contains("Brainstorm auth", initial);
|
||||||
|
Assert.Contains("review tokens", initial);
|
||||||
|
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
||||||
|
Assert.NotNull(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_TaskNotManual_Throws()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var queuedTask = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = listId,
|
||||||
|
Title = "x",
|
||||||
|
Status = TaskStatus.Queued,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CommitType = "feat",
|
||||||
|
};
|
||||||
|
await _tasks.AddAsync(queuedTask);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.StartAsync(queuedTask.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ChildTask_Throws()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
await _tasks.SetPlanningStartedAsync(parent.Id, "t");
|
||||||
|
var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.StartAsync(child.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResumeAsync_ReturnsExistingSessionDetails()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42");
|
||||||
|
|
||||||
|
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(parent.Id, resumeCtx.ParentTaskId);
|
||||||
|
Assert.Equal(resumeCtx.WorktreePath, resumeCtx.WorkingDir);
|
||||||
|
Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId);
|
||||||
|
Assert.True(Directory.Exists(resumeCtx.WorktreePath));
|
||||||
|
Assert.True(File.Exists(Path.Combine(resumeCtx.WorktreePath, ".mcp.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResumeAsync_NotPlanning_Throws()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
// did not start
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResumeAsync_NoClaudeSessionId_Throws()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
// UpdatePlanningSessionIdAsync not called
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.ResumeAsync(parent.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
|
||||||
|
var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, count);
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPendingDraftCountAsync_ReturnsDraftCount()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||||
|
await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);
|
||||||
|
|
||||||
|
var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(3, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
|
||||||
|
{
|
||||||
|
var (listId, _) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
|
|
||||||
|
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
|
||||||
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
||||||
|
Assert.Equal(TaskStatus.Manual, loaded!.Status);
|
||||||
|
Assert.Null(loaded.PlanningSessionToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscardAsync_RemovesWorktreeAndBranch()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
|
||||||
|
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(ctx.WorktreePath));
|
||||||
|
// branch deleted
|
||||||
|
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
|
||||||
|
Assert.Empty(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(wd);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
|
||||||
|
var t = await SeedManualTaskAsync(listId);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(wd, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
// Pre-create a colliding branch.
|
||||||
|
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
|
||||||
|
var head = await _git.RevParseHeadAsync(wd);
|
||||||
|
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
|
||||||
|
procInfo.ArgumentList.Add("branch");
|
||||||
|
procInfo.ArgumentList.Add(branch);
|
||||||
|
procInfo.ArgumentList.Add(head);
|
||||||
|
var p = System.Diagnostics.Process.Start(procInfo)!;
|
||||||
|
p.WaitForExit();
|
||||||
|
Assert.True(p.ExitCode == 0, $"git branch setup failed with exit {p.ExitCode}");
|
||||||
|
|
||||||
|
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
|
||||||
|
{
|
||||||
|
var (listId, wd) = await SeedListAsync();
|
||||||
|
var parent = await SeedManualTaskAsync(listId);
|
||||||
|
|
||||||
|
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Simulate the claude session capturing its session id.
|
||||||
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc");
|
||||||
|
|
||||||
|
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(startCtx.Token, resumeCtx.Token);
|
||||||
|
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
|
||||||
|
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ClaudeDo.Worker.Planning;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Planning;
|
||||||
|
|
||||||
|
public sealed class WindowsTerminalPlanningLauncherTests
|
||||||
|
{
|
||||||
|
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
|
||||||
|
{
|
||||||
|
var workingDir = wd ?? Path.GetTempPath();
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return new PlanningSessionStartContext(
|
||||||
|
ParentTaskId: "task-1",
|
||||||
|
WorkingDir: workingDir,
|
||||||
|
Token: "test-token",
|
||||||
|
WorktreePath: workingDir,
|
||||||
|
BranchName: "claudedo/planning/task1",
|
||||||
|
Files: new PlanningSessionFiles(
|
||||||
|
SessionDirectory: dir,
|
||||||
|
SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
|
||||||
|
InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LaunchStartAsync_WorkingDirMissing_Throws()
|
||||||
|
{
|
||||||
|
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
|
||||||
|
var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude");
|
||||||
|
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||||
|
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||||
|
Assert.Contains("Working directory", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LaunchStartAsync_WtMissing_Throws()
|
||||||
|
{
|
||||||
|
var ctx = MakeStartCtx();
|
||||||
|
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
||||||
|
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
||||||
|
|
||||||
|
var sut = new WindowsTerminalPlanningLauncher(
|
||||||
|
wtPath: "C:/no/such/wt.exe",
|
||||||
|
claudePath: "claude");
|
||||||
|
var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
|
||||||
|
sut.LaunchStartAsync(ctx, CancellationToken.None));
|
||||||
|
Assert.Contains("Windows Terminal", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class AppSettingsRepositoryTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(AppSettingsEntity.SingletonId, row.Id);
|
Assert.Equal(AppSettingsEntity.SingletonId, row.Id);
|
||||||
Assert.Equal("sonnet", row.DefaultModel);
|
Assert.Equal("sonnet", row.DefaultModel);
|
||||||
Assert.Equal(30, row.DefaultMaxTurns);
|
Assert.Equal(100, row.DefaultMaxTurns);
|
||||||
Assert.Equal("bypassPermissions", row.DefaultPermissionMode);
|
Assert.Equal("bypassPermissions", row.DefaultPermissionMode);
|
||||||
Assert.Equal("sibling", row.WorktreeStrategy);
|
Assert.Equal("sibling", row.WorktreeStrategy);
|
||||||
Assert.Null(row.CentralWorktreeRoot);
|
Assert.Null(row.CentralWorktreeRoot);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
Assert.Contains("-p", args);
|
Assert.Contains("-p", args);
|
||||||
Assert.Contains("--output-format stream-json", args);
|
Assert.Contains("--output-format stream-json", args);
|
||||||
Assert.Contains("--verbose", args);
|
Assert.Contains("--verbose", args);
|
||||||
Assert.Contains("--dangerously-skip-permissions", args);
|
Assert.Contains("--permission-mode auto", args);
|
||||||
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
Assert.Contains("--json-schema", args);
|
Assert.Contains("--json-schema", args);
|
||||||
Assert.DoesNotContain("--model", args);
|
Assert.DoesNotContain("--model", args);
|
||||||
Assert.DoesNotContain("--append-system-prompt", args);
|
Assert.DoesNotContain("--append-system-prompt", args);
|
||||||
@@ -110,11 +111,11 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PermissionMode_bypass_Keeps_DangerousFlag()
|
public void PermissionMode_bypass_Maps_To_Auto()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "bypassPermissions"));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "bypassPermissions"));
|
||||||
Assert.Contains("--dangerously-skip-permissions", args);
|
Assert.Contains("--permission-mode auto", args);
|
||||||
Assert.DoesNotContain("--permission-mode", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -126,10 +127,11 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PermissionMode_Null_Defaults_To_BypassPermissions()
|
public void PermissionMode_Null_Defaults_To_Auto()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
||||||
Assert.Contains("--dangerously-skip-permissions", args);
|
Assert.Contains("--permission-mode auto", args);
|
||||||
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
NullLogger<WorktreeManager>.Instance);
|
NullLogger<WorktreeManager>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedWorktree(
|
||||||
|
DbFixture db, string taskId, string path, string branchName, string baseCommit)
|
||||||
|
{
|
||||||
|
var wt = new WorktreeEntity
|
||||||
|
{
|
||||||
|
TaskId = taskId,
|
||||||
|
Path = path,
|
||||||
|
BranchName = branchName,
|
||||||
|
BaseCommit = baseCommit,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
await new WorktreeRepository(ctx).AddAsync(wt);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
|
private static async Task<(ListEntity list, TaskEntity task)> SeedListAndTask(
|
||||||
DbFixture db, string workingDir, TaskStatus status)
|
DbFixture db, string workingDir, TaskStatus status)
|
||||||
{
|
{
|
||||||
@@ -351,6 +367,118 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Equal("blocked", result.Status);
|
Assert.Equal("blocked", result.Status);
|
||||||
Assert.Contains("switch target branch", result.ErrorMessage ?? "");
|
Assert.Contains("switch target branch", result.ErrorMessage ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var first = await svc.MergeAsync(task.Id, "main", false, "msg",
|
||||||
|
leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, first.Status);
|
||||||
|
|
||||||
|
// Simulate the user resolving the conflict.
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
|
||||||
|
|
||||||
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
|
||||||
|
var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("aborted", result.Status);
|
||||||
|
Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")));
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Active, wt.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
|
||||||
|
var result = await svc.MergeAsync(
|
||||||
|
task.Id, "main", removeWorktree: false, "msg",
|
||||||
|
leaveConflictsInTree: true,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||||||
|
Assert.Contains("README.md", result.ConflictFiles);
|
||||||
|
|
||||||
|
var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"));
|
||||||
|
Assert.True(midMerge, "repo should be left in mid-merge state");
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Test doubles
|
#region Test doubles
|
||||||
|
|||||||
@@ -19,12 +19,45 @@ sealed class FakeWorkerClient : IWorkerClient
|
|||||||
public int FinalizePlanningCalls { get; private set; }
|
public int FinalizePlanningCalls { get; private set; }
|
||||||
public int WakeQueueCalls { get; private set; }
|
public int WakeQueueCalls { get; private set; }
|
||||||
|
|
||||||
|
public bool IsConnected => false;
|
||||||
|
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
|
public event Action<string, string>? TaskMessageEvent;
|
||||||
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
||||||
|
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
||||||
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
||||||
|
|
||||||
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
||||||
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
||||||
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||||
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||||
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
||||||
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
||||||
|
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; }
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) { DiscardPlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
||||||
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
|
public event Action<string>? PlanningMergeAbortedEvent;
|
||||||
|
public event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
||||||
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
||||||
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
||||||
|
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
|
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user