39 Commits

Author SHA1 Message Date
mika kuns
869cf72abe feat(ui): use a 24h TimePicker for prime schedule time entry
All checks were successful
Release / release (push) Successful in 35s
Replace the free-text time TextBox (which silently reset bad input to 07:00)
with Avalonia's TimePicker (24-hour, 5-minute steps), making invalid times
impossible. Drops the now-unused TimeSpanToHhmmConverter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:03:04 +02:00
mika kuns
f1715a34fa fix(ui): manual modal dragging, maximize/restore icon, day-toggle style
- Drive modal title-bar dragging manually via pointer capture + Window.Position;
  Avalonia 12's BeginMoveDrag and VisualRoot-as-Window cast no longer work
  (VisualRoot is a TopLevelHost). Applies to ModalShell and WorktreeModalView.
- Toggle the MainWindow maximize button between maximize/restore glyphs on
  WindowState changes (adds Icon.WinRestore geometry).
- Add the ToggleButton.day-toggle style used by the Prime weekday picker row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:02:56 +02:00
mika kuns
26998f05ff docs: describe recurring-weekday Prime schedule 2026-06-02 16:46:41 +02:00
mika kuns
7db8f213d8 feat(ui): replace prime date range with weekday toggle buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:43:28 +02:00
mika kuns
37738e3c8f feat(ui): drive prime schedule rows from weekday toggles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:40:41 +02:00
mika kuns
81fd186fb2 feat(worker): map prime schedule weekday bitmask over the hub 2026-06-02 16:33:11 +02:00
mika kuns
3127930454 test(worker): adapt prime scheduler tests to weekday model 2026-06-02 16:33:02 +02:00
mika kuns
bed4255a5e feat(worker): compute prime due-time from weekday bitmask
Also fixes PrimeScheduleRepository.ListAsync to sort client-side
(SQLite EF Core does not support TimeSpan in ORDER BY clauses).
2026-06-02 16:32:51 +02:00
mika kuns
dff06d9e35 feat(data): migrate prime schedules to days_of_week bitmask 2026-06-02 16:12:08 +02:00
mika kuns
0efad7a004 feat(data): persist weekday bitmask in prime schedule repo 2026-06-02 16:09:49 +02:00
mika kuns
eaf27e8b3a feat(data): model Prime schedule as weekday bitmask 2026-06-02 16:09:32 +02:00
mika kuns
13c3393e3a docs: implementation plan for recurring-weekday Prime
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:48:51 +02:00
mika kuns
4704a28e5d docs: spec for recurring-weekday Prime schedules
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:12:04 +02:00
mika kuns
1cb5171fba fix(worker): harden review re-run, timestamps, and queue affordance
- Clear ReviewFeedback only after a successful re-run so a failed/cancelled
  run keeps it for a manual retry.
- Clear stale StartedAt/FinishedAt when rejecting a task back to the queue.
- Only non-planning standalone tasks gate on review (guard PlanningPhase).
- Hide "send to queue" for WaitingForReview tasks so review isn't bypassed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:00:13 +02:00
mika kuns
4684a0af76 docs: document WaitingForReview state across project CLAUDE.md files
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:49:57 +02:00
mika kuns
6c27ffbdca feat(ui): surface review actions and WaitingForReview status in task rows
Adds Approve/Reject/Park/Cancel buttons with a feedback flyout, a review
status chip, and a friendly status label for WaitingForReview tasks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:46:37 +02:00
mika kuns
21f1cf2a85 feat(ui): add review hub methods and worker client wrappers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:19:41 +02:00
mika kuns
c88ed9d5eb feat(worker): add review_task MCP tool and status reference updates
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:17:56 +02:00
mika kuns
9c1f20f2d9 feat(worker): route standalone success to review and resume on re-queue
Standalone tasks now enter WaitingForReview on success; re-queued tasks
carrying reviewer feedback resume the prior Claude session with that
feedback as the next turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:15:57 +02:00
mika kuns
e8d018dd54 feat(worker): add review state transitions to TaskStateService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:10:34 +02:00
mika kuns
1ca32a6bdd feat(data): add WaitingForReview status and review_feedback column
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:08:33 +02:00
mika kuns
b86677d554 docs(plan): waiting-for-review implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:07:21 +02:00
mika kuns
3e072fae66 docs(spec): waiting-for-review task state design
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:03:14 +02:00
Mika Kuns
4a36fbe5e0 feat(ui): replay run log in session terminal, drop per-row live tail
All checks were successful
Release / release (push) Successful in 34s
Set the task's log path when the run is created (not at completion) so the
session terminal can replay live output when the user navigates away and back
mid-run. Remove the now-redundant inline per-row live tail (LiveTail /
HasLiveTail / TaskMessageEvent) and scroll the terminal to end after the next
layout pass so wrapping lines aren't clipped.
2026-06-01 16:25:14 +02:00
Mika Kuns
9e5a3fe962 merge: MCP surface — worktree/diff/merge/log tools + status-enum docs 2026-06-01 16:21:51 +02:00
Mika Kuns
3f98fd0ae5 merge: normalize list ID format to dashed UUID 2026-06-01 16:21:50 +02:00
Mika Kuns
8420b87bd1 merge: run reporting — token accounting + populate empty result 2026-06-01 16:21:50 +02:00
Mika Kuns
c0978df19a feat(claude-do): MCP surface: worktree/diff/merge/log tools + status-enum doc
BUNDLE — all changes live in src/ClaudeDo.Worker/External/ExternalMcpService.cs only, so this is one worktree / one merge. Do NOT touch run-recording or data-layer code (those are separate tasks). Reuse the existing services behind the UI modals (WorktreesOverviewModalView, DiffModalView, MergeModalView) — do not reimplement git plumbing. Build green after each addition.

Add these MCP tools:
1. g

ClaudeDo-Task: f6bdfb5b-8cbf-4e65-93d4-6c758a160484
2026-06-01 16:15:26 +02:00
Mika Kuns
3ac9e030e2 chore(claude-do): Normalize list ID format
list_task_lists returns two different ID formats: dashed UUIDs (e.g. "caed660e-109f-4e2a-b055-2c2722bf6fb7") and compact 32-char hex (e.g. "5c2cafcb33f044069ac324ac3fd84a16"). Mixing formats makes equality checks, logging, and lookups error-prone.

Fix: pick one canonical format (recommend dashed UUID) and normalize on write + migrate existing records. Ensure all ID-returning tools emit the same f

ClaudeDo-Task: fa8b69e0-6f8d-41d7-9a41-88db1360544d
2026-06-01 16:06:59 +02:00
Mika Kuns
4c6e6594dc fix(claude-do): Run reporting: token accounting + populate empty result
BUNDLE — both fixes live in the Worker run-recording / persistence layer (where a TaskRun is written after an agent finishes), NOT in ExternalMcpService.cs. Keep this disjoint from the MCP-surface bundle so the two can run in parallel without worktree conflicts. The DTO fields (tokensIn, tokensOut, resultMarkdown) already exist and are surfaced by list_runs/get_run — the bug is at write time.

1.

ClaudeDo-Task: 49a6060a-5044-4f1b-8665-5cfc064b8a82
2026-06-01 16:01:11 +02:00
mika kuns
5170914a7a feat(installer): optionally register ClaudeDo MCP server with Claude
Add an install step and welcome-page opt-in that registers the ClaudeDo
external MCP server with the Claude CLI. Failures are non-fatal and surface
the manual command so a missing or old CLI never blocks the install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:51:44 +02:00
mika kuns
b1f4349dab feat(worker): configurable max parallel task executions
Add a "Max parallel executions" setting to the General settings tab so
the queue can run more than one task concurrently. QueueService now
tracks multiple active slots and reads the limit from app settings each
cycle, so changes take effect without restarting the worker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:51:12 +02:00
Mika Kuns
23326a1833 merge: return confirmation payload from delete_task and cancel_task 2026-06-01 15:29:30 +02:00
Mika Kuns
ca0594328a merge: make add_task optional params actually optional 2026-06-01 15:29:29 +02:00
Mika Kuns
22d06acb35 merge: fix inconsistent timezone on timestamps (Z suffix) 2026-06-01 15:29:16 +02:00
Mika Kuns
ab44ba5e41 feat(ui): list reordering, quick actions, and resizable modals
- Drag-to-reorder user lists in the sidebar, persisted via a new
  list sort_order column (AddListSortOrder migration, backfilled by
  creation time) and ListRepository.ReorderAsync
- "Open in Explorer" / "Open in Terminal" context-menu actions on lists
- "Clear all completed" button on the Tasks island
- Inline-edit subtask titles (empty text deletes the step) and
  click-to-copy task ID in the Details island
- Make modal and planning windows resizable (BorderOnly decorations
  with min sizes) instead of fixed-size borderless

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:28:17 +02:00
mika kuns
6c3afce329 chore(claude-do): Return confirmation payload from delete_task and cancel_task
delete_task (and likely cancel_task) return no output on success. Silent success is indistinguishable from a no-op, so callers can't verify the action took effect.

Fix: return a small confirmation object, e.g. { deleted: true, id } / { cancelled: true, id }. Indicate not-found vs deleted distinctly.

ClaudeDo-Task: 97a87ebb-0d87-4ee0-800c-aa1a0b3a06c5
2026-06-01 15:20:20 +02:00
mika kuns
f8e387bbc1 chore(claude-do): Make add_task optional params actually optional
add_task currently marks description, createdBy, and queueImmediately as required, forcing callers to invent values for fields that have obvious defaults.

Fix: make them optional with sensible defaults — description: null, queueImmediately: false, createdBy: server default like "mcp". Keep only listId and title as truly required.

ClaudeDo-Task: b9fadf0b-a20e-4deb-932d-29ef9c0b83f3
2026-06-01 15:18:27 +02:00
mika kuns
2a36998ac7 chore(claude-do): Fix inconsistent timezone on timestamps
Timestamps are serialized inconsistently across tools. add_task returns createdAt with a trailing 'Z' (e.g. "2026-06-01T13:03:56.1636946Z"), but get_task and list_runs return the same value WITHOUT the 'Z'. This is a timezone-ambiguity bug.

Fix: serialize all DateTime values as UTC with the 'Z' suffix consistently (use a single shared JSON serializer setting / DateTimeKind=Utc). Audit every tool

ClaudeDo-Task: 4bbc759e-ff05-45e3-a57f-b290c7e16264
2026-06-01 15:16:25 +02:00
100 changed files with 6863 additions and 375 deletions

View File

@@ -35,7 +35,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) - EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker) - `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder - Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled - Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled).
- Worktree state flow: Active -> Merged | Discarded | Kept - Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed - The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged) - Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)

View File

@@ -0,0 +1,175 @@
# Waiting for Review — Task State — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `WaitingForReview` lifecycle state that standalone tasks enter after a successful run, with approve / reject-rerun / reject-park / cancel exits, exposed via UI and MCP.
**Architecture:** New enum value + nullable `ReviewFeedback` column. `TaskStateService` gains review transitions. `TaskRunner.HandleSuccess` routes standalone-task success to review. `QueueService.RunInSlotAsync` resumes the Claude session when re-running a rejected task. New MCP `review_task` tool + UI commands.
**Tech Stack:** .NET 8, EF Core (SQLite, TEXT enum), SignalR, Avalonia MVVM, xUnit.
**Scope decision (locked):** Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview`. Planning **child** tasks continue to `Done` on success so the sequential planning chain (which advances on *terminal* states) is unaffected. Flagged for user confirmation.
---
## Task 1: Data layer — enum, converter, column
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- Create: EF migration via CLI
- [ ] **Step 1:** Add `WaitingForReview` to `TaskStatus` enum (after `Running`) and add `public string? ReviewFeedback { get; set; }` to `TaskEntity`.
- [ ] **Step 2:** In `TaskEntityConfiguration`, add `TaskStatus.WaitingForReview => "waiting_for_review"` to `StatusToString` and `"waiting_for_review" => TaskStatus.WaitingForReview` to `StatusFromString`; map the column: `builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");`
- [ ] **Step 3:** Create migration: `dotnet ef migrations add AddReviewFeedback --project src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Verify it only adds the `review_feedback` TEXT column (nullable). If `dotnet ef` unavailable, hand-write the migration + designer following the latest migration in `Migrations/`.
- [ ] **Step 4:** Build `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Expected: success.
- [ ] **Step 5:** Commit.
## Task 2: Worker — review transitions in TaskStateService
**Files:**
- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs`
- Modify: `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs` (add new method signatures)
- Test: `tests/ClaudeDo.Worker.Tests/...` (state transition tests)
New methods (all return `TransitionResult`, broadcast `TaskUpdated`):
- `SubmitForReviewAsync(taskId, finishedAt, result, ct)` — guard `Status == Running`; set `Status=WaitingForReview, FinishedAt, Result`. Does NOT call `OnChildTerminalAsync` (review is non-terminal; only invoked for standalone tasks anyway).
- `ApproveReviewAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Done`.
- `RejectToQueueAsync(taskId, feedback, ct)` — reject empty/whitespace feedback (`TransitionResult(false, "Feedback is required to reject for re-run.")`); guard `Status == WaitingForReview`; set `Status=Queued, ReviewFeedback=feedback`; `_waker.Wake()`.
- `RejectToIdleAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Idle, ReviewFeedback=null` (leave `Result` intact).
- `ClearReviewFeedbackAsync(taskId, ct)` — set `ReviewFeedback=null` (no status change, no guard); used by the runner after consuming feedback.
- Extend `CancelAsync` guard: `(Status == Running || Status == Queued || Status == WaitingForReview)`.
- [ ] **Step 1:** Write failing tests in a new `tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs` (follow existing TaskStateService test setup). Cover: submit-for-review from Running; approve from WaitingForReview→Done; reject-to-queue stores feedback + status Queued; empty feedback rejected; reject-to-idle clears feedback + keeps Result; cancel from WaitingForReview→Cancelled; invalid (approve from Idle) returns `!Ok`.
- [ ] **Step 2:** Run tests, expect FAIL (methods missing).
- [ ] **Step 3:** Implement the methods + interface signatures + CancelAsync guard.
- [ ] **Step 4:** Run tests, expect PASS.
- [ ] **Step 5:** Commit.
## Task 3: Worker — route standalone success to review
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess`)
- [ ] **Step 1:** In `HandleSuccess`, after commit, branch:
```csharp
var finishedAt = DateTime.UtcNow;
if (task.ParentTaskId is null)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
```
- [ ] **Step 2:** Build worker. Expected: success.
- [ ] **Step 3:** Commit.
## Task 4: Worker — resume-aware re-run in QueueService
**Files:**
- Modify: `src/ClaudeDo.Worker/Queue/QueueService.cs` (`RunInSlotAsync`)
- Test: `tests/ClaudeDo.Worker.Tests/...`
- [ ] **Step 1:** In `RunInSlotAsync`, after loading `task`:
```csharp
if (!string.IsNullOrWhiteSpace(task.ReviewFeedback))
{
var feedback = task.ReviewFeedback!;
string? sessionId;
using (var ctx = _dbFactory.CreateDbContext())
sessionId = (await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
await _state.ClearReviewFeedbackAsync(taskId, ct); // inject ITaskStateService
if (sessionId is not null)
{
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
return;
}
task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer feedback: {feedback}"
: $"{task.Description}\n\nReviewer feedback: {feedback}";
}
await _runner.RunAsync(task, "queue", ct);
```
Inject `ITaskStateService _state` into `QueueService` (add to ctor + DI already provides it).
- [ ] **Step 2:** Build worker, expect success.
- [ ] **Step 3:** Commit.
## Task 5: MCP — review_task tool + status reference
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- [ ] **Step 1:** Add `review_task` tool:
```csharp
[McpServerTool, Description(
"Review a task that is WaitingForReview. decision: 'approve' (→ Done), " +
"'reject_rerun' (→ Queued, resumes the agent session with feedback — feedback required), " +
"'reject_park' (→ Idle for manual editing), 'cancel' (→ Cancelled). ")]
public async Task<TaskDto> ReviewTask(string taskId, string decision, string? feedback, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult r = decision.ToLowerInvariant() switch
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException($"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
if (!r.Ok) throw new InvalidOperationException(r.Reason ?? "Review action failed.");
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
```
- [ ] **Step 2:** Add `WaitingForReview` to `GetTaskStatusValues` list; update the validation strings in `ListTasks` and the lifecycle text in `GetTask`/`UpdateTaskStatus` to include `WaitingForReview`.
- [ ] **Step 3:** Build worker, expect success.
- [ ] **Step 4:** Commit.
## Task 6: UI — client + hub methods
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- [ ] **Step 1:** Hub: add `ApproveReview(taskId)`, `RejectReviewToQueue(taskId, feedback)`, `RejectReviewToIdle(taskId)`, `CancelReview(taskId)` — each calls the matching `_state` method via `HubGuard`-style mapping (`if (!result.Ok) throw new HubException(...)`).
- [ ] **Step 2:** `IWorkerClient` + `WorkerClient`: add `ApproveReviewAsync`, `RejectReviewToQueueAsync(taskId, feedback)`, `RejectReviewToIdleAsync`, `CancelReviewAsync` invoking the hub methods. Add no-op/stub impls to `StubWorkerClient`.
- [ ] **Step 3:** Build App + Ui.Tests. Expected: success.
- [ ] **Step 4:** Commit.
## Task 7: UI — converter, row VM, view buttons
**Files:**
- Modify: `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (commands)
- Modify: the task row/detail AXAML to surface Approve / Reject / Park / Cancel when `IsWaitingForReview`
- [ ] **Step 1:** `StatusColorConverter`: add `"waiting_for_review" => Brushes.MediumPurple,` (placeholder — user does visual pass).
- [ ] **Step 2:** `TaskRowViewModel`: add `public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;`, raise it in `OnStatusChanged`, and add `(TaskStatus.WaitingForReview, _) => "review"` to `StatusChipClass`.
- [ ] **Step 3:** `TasksIslandViewModel`: add relay commands `ApproveReview`, `RejectReviewRerun` (prompts for feedback), `RejectReviewPark`, `CancelReview` operating on the selected/target row, calling the new client methods.
- [ ] **Step 4:** Add buttons to the relevant view bound to those commands, visible when `IsWaitingForReview`. Reject-rerun uses a text-input flyout/dialog for required feedback.
- [ ] **Step 5:** Build App + Ui.Tests. Expected: success. (Visual layout: flagged for user's visual pass — cannot render here.)
- [ ] **Step 6:** Commit.
## Task 8: Docs + full verification
**Files:**
- Modify: root `CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
- [ ] **Step 1:** Update status flow lines + worker transition table to include `WaitingForReview` and the new transitions.
- [ ] **Step 2:** Build all projects (csproj individually — `.slnx` needs .NET 9) and run `dotnet test tests/ClaudeDo.Worker.Tests`, `tests/ClaudeDo.Ui.Tests`, `tests/ClaudeDo.Data.Tests`. Expected: all green.
- [ ] **Step 3:** Commit.
## Self-Review notes
- Spec coverage: §1 state machine → Tasks 2,3; §2 data → Task 1; §3 transitions → Task 2; §4 resume → Task 4; §5 MCP → Task 5; §6 hub → Task 6; §7 UI → Tasks 6,7; §8 docs → Task 8; testing → Tasks 2,4,8.
- Method names consistent across tasks: `SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync` (state); `ApproveReview`/`RejectReviewToQueue`/`RejectReviewToIdle`/`CancelReview` (hub); `ApproveReviewAsync`/`RejectReviewToQueueAsync`/`RejectReviewToIdleAsync`/`CancelReviewAsync` (client).

View File

@@ -0,0 +1,983 @@
# Prime Recurring Weekday Schedule — 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:** Replace the Prime schedule's date-range model with a recurring weekday model — pick a set of weekdays plus a time, and the ping fires on the next eligible day the worker is running.
**Architecture:** A `[Flags] PrimeDays` weekday bitmask stored as a single `days_of_week` int column replaces `StartDate`/`EndDate`/`WorkdaysOnly`. `NextDueCalculator` walks forward to the next selected weekday; the existing 30-minute catch-up and already-fired-today logic are untouched. UI swaps the range picker + MonFri checkbox for seven toggle buttons. Both SignalR DTO copies carry a single `int Days`.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-02-prime-recurring-weekdays-design.md`
**Build/test note:** `dotnet build ClaudeDo.slnx` needs .NET 9; on .NET 8 build individual csproj. Commands in this plan use the per-project form.
---
## File Structure
- `src/ClaudeDo.Data/Models/PrimeDays.cs`**new**, `[Flags]` enum.
- `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` — swap fields.
- `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` — column mapping.
- `src/ClaudeDo.Data/Migrations/*` — new migration + snapshot.
- `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` — upsert fields + ordering.
- `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` — weekday eligibility.
- `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs``ToDto` mapping.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — list/upsert mapping.
- `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` — 7 day bools.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` — defaults + validation.
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``day-toggle` style class.
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — row template.
- Tests: `NextDueCalculatorTests`, `PrimeSchedulerTests`, `PrimeScheduleRepositoryTests`, `PrimeClaudeTabViewModelTests`.
- Docs: `src/ClaudeDo.Data/CLAUDE.md`, root `CLAUDE.md`.
---
## Task 1: PrimeDays enum + entity + configuration
**Files:**
- Create: `src/ClaudeDo.Data/Models/PrimeDays.cs`
- Modify: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`
- [ ] **Step 1: Create the flags enum**
`src/ClaudeDo.Data/Models/PrimeDays.cs`:
```csharp
namespace ClaudeDo.Data.Models;
[Flags]
public enum PrimeDays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
All = Weekdays | Saturday | Sunday, // 127
}
```
- [ ] **Step 2: Swap entity fields**
In `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`, remove `StartDate`, `EndDate`, `WorkdaysOnly` and add `Days`. Result:
```csharp
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
public TimeSpan TimeOfDay { get; set; }
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
```
- [ ] **Step 3: Update entity configuration**
In `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`, replace the `start_date`/`end_date`/`workdays_only` property lines with a `days_of_week` mapping (EF maps the enum to INTEGER automatically):
```csharp
builder.Property(s => s.Days).HasColumnName("days_of_week")
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
```
Leave `Id`, `LastRunAt`, `PromptOverride`, `CreatedAt` mappings unchanged. Add `using ClaudeDo.Data.Models;` if not present (it already is).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: FAILS — `PrimeScheduleRepository`, snapshot, etc. still reference removed fields. That is expected; Tasks 23 fix it. (If you prefer a clean build gate, proceed to Task 2 before building.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/PrimeDays.cs src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
git commit -m "feat(data): model Prime schedule as weekday bitmask"
```
---
## Task 2: Repository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`
- [ ] **Step 1: Update `ListAsync` ordering**
The old ordering used `StartDate`. Order by `TimeOfDay`:
```csharp
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{
var rows = await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.TimeOfDay)
.ToListAsync(ct);
return rows;
}
```
- [ ] **Step 2: Update `UpsertAsync` field copy**
Replace the three removed-field assignments with `Days`:
```csharp
else
{
existing.Days = entity.Days;
existing.TimeOfDay = entity.TimeOfDay;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
}
```
Leave `GetAsync`, `DeleteAsync`, `UpdateLastRunAsync` unchanged.
- [ ] **Step 3: Commit** (build verified after migration in Task 3)
```bash
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
git commit -m "feat(data): persist weekday bitmask in prime schedule repo"
```
---
## Task 3: EF migration
**Files:**
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_PrimeWeekdays.cs` (generated)
- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (generated)
- [ ] **Step 1: Generate the migration**
Run from repo root:
```bash
dotnet ef migrations add PrimeWeekdays --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: a new `*_PrimeWeekdays.cs` file and an updated snapshot. (If `dotnet ef` is unavailable, hand-write the migration using the body below.)
- [ ] **Step 2: Replace the generated `Up` body with an explicit backfill**
EF's auto-generated drop/add would discard existing schedules' weekday intent. Edit the new migration's `Up` to add the column, backfill from `workdays_only`, then drop the old columns:
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "days_of_week",
table: "prime_schedules",
type: "INTEGER",
nullable: false,
defaultValue: 31);
migrationBuilder.Sql(
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
}
```
- [ ] **Step 3: Replace the generated `Down` body**
```csharp
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "start_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
migrationBuilder.AddColumn<DateOnly>(
name: "end_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
migrationBuilder.AddColumn<bool>(
name: "workdays_only", table: "prime_schedules",
type: "INTEGER", nullable: false, defaultValue: true);
migrationBuilder.Sql(
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
}
```
Add `using System;` at the top of the migration file if `DateOnly` defaults require it (the existing AddPrimeSchedules migration already imports `System`).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Migrations
git commit -m "feat(data): migrate prime schedules to days_of_week bitmask"
```
---
## Task 4: Worker DTO + NextDueCalculator (TDD)
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`
- [ ] **Step 1: Update the Worker DTO**
`src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`:
```csharp
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
```
- [ ] **Step 2: Rewrite the calculator tests**
Replace the entire body of `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`. Note: 2026-05-05 is a Tuesday; 2026-05-08 is a Friday; 2026-05-09/10 are Sat/Sun; 2026-05-11 is a Monday.
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests
{
private static PrimeScheduleDto Schedule(
PrimeDays days, TimeSpan time,
bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
[Fact]
public void Disabled_Schedule_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void No_Days_Selected_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.None, new(7, 0, 0));
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void Future_Same_Day_Returns_Today_At_Target()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
Assert.False(r.FireImmediately);
}
[Fact]
public void Within_CatchUp_Window_Fires_Immediately()
{
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.True(r!.FireImmediately);
}
[Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Weekdays_Only_Skips_Weekend()
{
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Single_Day_Schedule_Targets_That_Weekday()
{
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Multiple_Schedules_Returns_Earliest()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var early = Schedule(PrimeDays.All, new(7, 0, 0));
var late = Schedule(PrimeDays.All, new(9, 0, 0));
var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id);
}
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: FAIL — `PrimeScheduleDto` no longer has `StartDate`/`EndDate`/`workdaysOnly`, and the calculator still references them (compile errors).
- [ ] **Step 4: Rewrite the calculator**
Replace the entire body of `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`:
```csharp
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
public static class NextDueCalculator
{
public static NextDue? Compute(
IEnumerable<PrimeScheduleDto> schedules,
DateTimeOffset now,
TimeSpan catchUp)
{
NextDue? best = null;
foreach (var s in schedules)
{
if (!s.Enabled) continue;
var due = ComputeFor(s, now, catchUp);
if (due is null) continue;
if (best is null || due.At < best.At) best = due;
}
return best;
}
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
{
if ((PrimeDays)s.Days == PrimeDays.None) return null;
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
{
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (todayTarget >= now)
return new NextDue(s, todayTarget, false);
if (now <= todayTarget + catchUp)
return new NextDue(s, now, true);
}
var d = todayLocal.AddDays(1);
for (int i = 0; i < 7; i++)
{
if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1);
}
return null;
}
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => PrimeDays.Monday,
DayOfWeek.Tuesday => PrimeDays.Tuesday,
DayOfWeek.Wednesday => PrimeDays.Wednesday,
DayOfWeek.Thursday => PrimeDays.Thursday,
DayOfWeek.Friday => PrimeDays.Friday,
DayOfWeek.Saturday => PrimeDays.Saturday,
DayOfWeek.Sunday => PrimeDays.Sunday,
_ => PrimeDays.None,
};
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
}
```
- [ ] **Step 5: Run the calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: still FAILS to build — `PrimeScheduler.ToDto` and `WorkerHub` mappings reference removed fields. Proceed to Tasks 56, then re-run.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
git commit -m "feat(worker): compute prime due-time from weekday bitmask"
```
---
## Task 5: PrimeScheduler.ToDto + scheduler tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
- [ ] **Step 1: Update the `ToDto` mapping**
Replace the `ToDto` method in `PrimeScheduler.cs`:
```csharp
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
```
- [ ] **Step 2: Update scheduler test fixtures**
In `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`, every `new PrimeScheduleEntity { ... }` initializer sets `StartDate`/`EndDate`/`WorkdaysOnly`. Replace those three lines in each of the three initializers (lines ~48-52, ~89-94, ~131-136) with a single `Days` assignment. Each initializer becomes:
```csharp
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add `using ClaudeDo.Data.Models;` to the file's usings if not already present (it is, via line 1).
- [ ] **Step 3: Run scheduler + calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Prime"`
Expected: still build-fails until `WorkerHub` (Task 6) compiles. After Task 6, this command must PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
git commit -m "test(worker): adapt prime scheduler tests to weekday model"
```
---
## Task 6: WorkerHub mapping + repository tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:488-518`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
- [ ] **Step 1: Update `ListPrimeSchedules`**
```csharp
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto(
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
}
```
- [ ] **Step 2: Update `UpsertPrimeSchedule`**
```csharp
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new PrimeScheduleRepository(ctx);
var existing = await repo.GetAsync(dto.Id);
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
TimeOfDay = dto.TimeOfDay,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
}
```
`DeletePrimeSchedule` is unchanged.
- [ ] **Step 3: Update repository tests**
In `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`, replace each entity initializer's `StartDate`/`EndDate`/`WorkdaysOnly` lines with `Days = PrimeDays.Weekdays,` (drop them where only `StartDate`/`EndDate` appear). The three initializers become:
```csharp
// Upsert_Then_List_RoundTrips
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// UpdateLastRunAt_Persists
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// Delete_Removes_Row
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = TimeSpan.Zero,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add an assertion in `Upsert_Then_List_RoundTrips` after the existing time assertion:
```csharp
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
```
- [ ] **Step 4: Build worker + run all worker tests**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all Prime + repository tests green).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
git commit -m "feat(worker): map prime schedule weekday bitmask over the hub"
```
---
## Task 7: UI DTO + ViewModels + tests (TDD)
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
- [ ] **Step 1: Update the UI DTO**
`src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` (keep `PrimeFiredEvent` unchanged):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
public sealed record PrimeFiredEvent(
Guid ScheduleId,
bool Success,
string Message,
DateTimeOffset FiredAt);
```
- [ ] **Step 2: Rewrite the row VM**
Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`:
```csharp
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
public Guid Id { get; }
public bool IsExisting { get; }
[ObservableProperty] private bool _enabled;
[ObservableProperty] private bool _monday;
[ObservableProperty] private bool _tuesday;
[ObservableProperty] private bool _wednesday;
[ObservableProperty] private bool _thursday;
[ObservableProperty] private bool _friday;
[ObservableProperty] private bool _saturday;
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
{
Id = dto.Id;
IsExisting = isExisting;
Enabled = dto.Enabled;
Monday = (dto.Days & Mon) != 0;
Tuesday = (dto.Days & Tue) != 0;
Wednesday = (dto.Days & Wed) != 0;
Thursday = (dto.Days & Thu) != 0;
Friday = (dto.Days & Fri) != 0;
Saturday = (dto.Days & Sat) != 0;
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay;
LastRunAt = dto.LastRunAt;
}
public int DaysMask()
{
int m = 0;
if (Monday) m |= Mon;
if (Tuesday) m |= Tue;
if (Wednesday) m |= Wed;
if (Thursday) m |= Thu;
if (Friday) m |= Fri;
if (Saturday) m |= Sat;
if (Sunday) m |= Sun;
return m;
}
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
}
```
- [ ] **Step 3: Update the tab VM**
In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`, replace `Validate` and `AddSchedule`:
```csharp
public string? Validate()
{
foreach (var r in Rows)
{
if (r.DaysMask() == 0)
return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59.";
}
return null;
}
```
```csharp
[RelayCommand]
private void AddSchedule()
{
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // MonFri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null);
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
}
```
`LoadAsync`, `SaveAsync`, `RemoveSchedule`, `ApplyFiredEvent` are unchanged.
- [ ] **Step 4: Rewrite the tab VM tests**
Replace the body of `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PrimeClaudeTabViewModelTests
{
private sealed class FakeApi : IPrimeScheduleApi
{
public List<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(dto);
}
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null);
[Fact]
public async Task Load_Populates_Rows()
{
var api = new FakeApi();
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
Assert.Single(vm.Rows);
}
[Fact]
public void AddSchedule_Appends_Row_With_Defaults()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].Monday);
Assert.True(vm.Rows[0].Friday);
Assert.False(vm.Rows[0].Saturday);
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
}
[Fact]
public void Row_Decomposes_And_Recomposes_Days()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
Assert.Equal(31, row.DaysMask());
row.Saturday = true;
Assert.Equal(63, row.DaysMask());
}
[Fact]
public async Task Save_Diffs_New_And_Removed_Rows()
{
var api = new FakeApi();
var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid();
api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
vm.AddScheduleCommand.Execute(null);
await vm.SaveAsync();
Assert.Contains(deletedId, api.Deletes);
Assert.Equal(2, api.Upserts.Count);
}
[Fact]
public void Validate_Reports_No_Days_Selected()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
Assert.NotNull(vm.Validate());
}
[Fact]
public void Validate_Passes_With_One_Day()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate());
}
}
```
- [ ] **Step 5: Run UI tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter FullyQualifiedName~PrimeClaudeTabViewModelTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
git commit -m "feat(ui): drive prime schedule rows from weekday toggles"
```
---
## Task 8: XAML — toggle-button row
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add a `day-toggle` style class**
Append to `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (inside the root `<Styles>` element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors:
```xml
<Style Selector="ToggleButton.day-toggle">
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style>
```
If `AccentBrush` is not a defined token, use the brush the project uses for primary/selected affordances (check the `primary` button style in this file and reuse that brush). Final visual pass is the user's.
- [ ] **Step 2: Replace the Prime row template**
In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, replace the `<Grid ...>` inside the Prime `DataTemplate` (currently columns `Auto,*,Auto,Auto,Auto,Auto` with the `ThemedDatePicker` and MonFri checkbox) with:
```xml
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2" Width="64"
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
```
- [ ] **Step 3: Update the explainer text**
Replace the intro `TextBlock` Text in the Prime tab (`SettingsModalView.axaml`):
```xml
Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
```
- [ ] **Step 4: Remove the now-unused range converter (only if unreferenced)**
The `DateOnlyToDateTime` resource on line 23 was used only by the range picker. Grep the file: if `DateOnlyToDateTime` has no other reference, remove the `<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>` line. Keep `TimeSpanToHhmm` (still used).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 5: Manual UI check**
Start the worker, then the app. Open Settings → Prime Claude. Verify: a row shows 7 toggle buttons with MonFri lit by default; toggling Sat/Sun persists after Save+reopen; clearing all days shows the validation error on Save. (UI correctness can only be confirmed in the running app — state so explicitly if it cannot be run.)
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): replace prime date range with weekday toggle buttons"
```
---
## Task 9: Docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Update the Data CLAUDE.md**
In `src/ClaudeDo.Data/CLAUDE.md`, the Models section has no PrimeSchedule line today; add one under Models, and confirm the `prime_schedules` table mention in the Schema section stays accurate:
```markdown
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
```
- [ ] **Step 2: Update the root CLAUDE.md if Prime is described**
Grep `CLAUDE.md` for "Prime"; if there is a Prime description mentioning a date range, update it to "recurring weekday schedule". If there is no such line, make no change.
- [ ] **Step 3: Full test sweep**
Run: `dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md CLAUDE.md
git commit -m "docs: describe recurring-weekday Prime schedule"
```
---
## Self-Review Notes
- **Spec coverage:** data model (T1), scheduling logic (T4), UI toggles (T7T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4T7), out-of-scope items excluded. ✓
- **Type consistency:** entity `PrimeDays Days`; both DTOs `int Days`; hub/scheduler cast `(int)`/`(PrimeDays)` at boundaries; calculator casts `(PrimeDays)s.Days`; row VM exposes 7 bools + `DaysMask()`. ✓
- **Build ripple:** a single type change breaks several projects at once, so some intermediate steps note expected build failures; the gating green builds are T3 Step 4 (Data), T6 Step 4 (Worker + tests), T8 Step 4 (App). ✓
```

View File

@@ -0,0 +1,123 @@
# Waiting for Review — Task State — Design
**Date:** 2026-06-01
**Status:** Approved (brainstorming)
**Scope:** `ClaudeDo.Data` (TaskEntity, EF config + migration), `ClaudeDo.Worker` (TaskStateService, TaskRunner, QueueService, WorkerHub, ExternalMcpService), `ClaudeDo.Ui` (StatusColorConverter, TaskRowViewModel, views), CLAUDE.md docs
## Problem
A successful task run currently transitions straight to `Done` and is considered complete. There is no gate for a human (or another agent) to review the result before it is accepted. We want review to be a mandatory step: after a successful run a task waits for an explicit approval, and a reviewer can send it back with feedback for another turn.
## Goals
- Add a `WaitingForReview` lifecycle state that a task enters automatically after a **successful** run.
- Reviewer can **approve** (→ `Done`), **reject-and-re-run** (→ `Queued`, resuming the same Claude session with required feedback), **reject-and-park** (→ `Idle`), or **cancel** (→ `Cancelled`).
- Reject-and-re-run reuses the existing session-resume mechanism so the agent continues with full context.
- Both the desktop UI and the external MCP surface can perform review actions.
## Non-Goals
- No change to the failure path: a **failed** run still goes straight to `Failed`, never to `WaitingForReview`.
- No change to planning-phase finalization. A planning parent that generates child tasks keeps its current behavior and does **not** route through review. Only ordinary executable runs (`Running` → success) are affected.
- No change to worktree state flow (`Active | Merged | Discarded | Kept`).
- No change to the in-run auto-retry-on-failure behavior; only the *final* successful completion routes to review.
## Design
### 1. State machine
Changed/added transitions in **bold**:
| From | To | Trigger |
|---|---|---|
| Idle | Queued | enqueue (unchanged) |
| Queued | Running | queue picker claim (unchanged) |
| Running | **WaitingForReview** | **successful run (was → Done)** |
| Running | Failed | failed run (unchanged) |
| Running | Cancelled | cancel during run (unchanged) |
| **WaitingForReview** | **Done** | **approve** |
| **WaitingForReview** | **Queued** | **reject + required feedback → resume re-run** |
| **WaitingForReview** | **Idle** | **reject → park for manual edit** |
| **WaitingForReview** | **Cancelled** | **abandon an almost-done task** |
| Done \| Failed \| Cancelled | Idle | reset (unchanged) |
### 2. Data model
`ClaudeDo.Data`:
- `TaskStatus` enum (`Models/TaskEntity.cs`): add `WaitingForReview` after `Running`.
- EF string converter (`Configuration/TaskEntityConfiguration.cs`): map `WaitingForReview``"waiting_for_review"` (TEXT column, no schema constraint to change).
- New nullable column **`ReviewFeedback : string?`** on `TaskEntity`. Holds the reviewer's rejection comment until the re-run consumes it, then it is cleared. Persisted so it survives a worker restart and is visible to the UI.
- One EF migration: add the `review_feedback` column. No backfill — the new status value and column are only written going forward.
### 3. Worker — status transitions (`State/TaskStateService.cs`)
`TaskStateService` remains the sole owner of status writes. New/changed methods:
- `SubmitForReviewAsync(taskId)``Running``WaitingForReview`. Sets `FinishedAt` and `Result` exactly as `CompleteAsync` does today. Called by `TaskRunner` on success **instead of** `CompleteAsync`. (`CompleteAsync` is retained for the approve path.)
- `ApproveReviewAsync(taskId)``WaitingForReview``Done`.
- `RejectToQueueAsync(taskId, feedback)``WaitingForReview``Queued`. Rejects empty/whitespace feedback with a failed `TransitionResult`. Stores `feedback` in `ReviewFeedback`. Wakes the queue.
- `RejectToIdleAsync(taskId)``WaitingForReview``Idle`. Parks for manual editing; leaves `Result` intact, clears `ReviewFeedback`.
- `CancelAsync` — extend the allowed source states to include `WaitingForReview`.
Each transition broadcasts `TaskUpdated` as today. Invalid source states return a failed `TransitionResult` (no throw), matching existing convention.
### 4. Resume-aware re-run (`Queue/QueueService.cs`)
The queue picker still atomically claims a `Queued`, unblocked task (`UPDATE … SET status='running' … RETURNING *`). The `RETURNING` row already carries `ReviewFeedback`. After a successful claim, `QueueService` branches:
1. **`ReviewFeedback` set + latest run has a `SessionId`** → `TaskRunner.ContinueAsync(task, feedback)``--resume {sessionId}` with `feedback` as the next-turn prompt.
2. **`ReviewFeedback` set, no prior `SessionId`** (edge case) → `TaskRunner.RunAsync` with the feedback appended to the task prompt, so the comment is not lost.
3. **No `ReviewFeedback`** → normal `TaskRunner.RunAsync` (fresh session).
`ReviewFeedback` is cleared once consumed (single UPDATE), so a later re-run does not re-apply stale feedback.
### 5. External MCP surface (`External/ExternalMcpService.cs`)
- New tool **`review_task(taskId, decision, feedback?)`**, `decision ∈ {approve, reject_rerun, reject_park, cancel}`. `feedback` is required when `decision = reject_rerun` (validation error otherwise). Maps onto the `TaskStateService` methods in §3. This lets automation / other agents act as reviewers.
- `get_task_status_values` — add `WaitingForReview` with a description covering the four exit actions.
- `list_tasks` status-filter parsing and validation message — include `WaitingForReview`.
- `get_task` lifecycle description text — update to `Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled`.
- `update_task_status` stays restricted to `Idle` and `Queued`; all review decisions go through `review_task` (keeps the "set status freely" affordance and the review affordance distinct).
### 6. Worker hub (`Hub/WorkerHub.cs` + `Hub/HubBroadcaster.cs`)
New hub methods called by the UI, each delegating to `TaskStateService`:
- `ApproveReview(taskId)`
- `RejectReviewToQueue(taskId, feedback)`
- `RejectReviewToIdle(taskId)`
Cancel already exists. No new broadcast events — `TaskUpdated` covers it.
### 7. UI (`ClaudeDo.Ui`)
- `Converters/StatusColorConverter.cs`: add a `waiting_for_review` case. Snap to an existing color token from the scale; final visual pass is left to the user (per project convention — centralize/tokenize, user does the visual pass).
- `ViewModels/Islands/TaskRowViewModel.cs`: add `IsWaitingForReview` computed property and commands **Approve**, **RejectRerun**, **RejectPark**, **Cancel** (the last reuses the existing cancel command). Commands are enabled only when `Status == WaitingForReview`.
- Reject-Rerun opens a small flyout/dialog with a required multi-line feedback text box; on confirm it calls `RejectReviewToQueue(taskId, feedback)`.
- Wire the commands to the new SignalR client methods.
### 8. Docs
Update the status flow in:
- root `CLAUDE.md` — "Task status flow" line.
- `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity status list.
- `src/ClaudeDo.Worker/CLAUDE.md` — status-model transition table.
## Testing
`ClaudeDo.Worker.Tests` (real SQLite + real git, existing harness):
- `SubmitForReviewAsync`: a successful run lands in `WaitingForReview`, not `Done`.
- `ApproveReviewAsync`: `WaitingForReview``Done`.
- `RejectToQueueAsync`: empty feedback rejected; valid feedback stored in `ReviewFeedback` and status → `Queued`.
- `RejectToIdleAsync`: → `Idle`, `Result` preserved, `ReviewFeedback` cleared.
- `CancelAsync` from `WaitingForReview``Cancelled`.
- Invalid source states (e.g. approve from `Idle`) return a failed `TransitionResult`.
- Resume-aware re-run: a task with `ReviewFeedback` + a prior `SessionId`, when claimed, resumes the session with the feedback as the prompt and clears `ReviewFeedback`.
- `review_task` MCP tool: each decision maps to the correct transition; `reject_rerun` without feedback errors.
## Open questions
None outstanding. Planning-task exclusion (Non-Goals) is the one assumption to verify against the planning-finalization code path during implementation; if planning finalization shares `CompleteAsync`, route only the executable-run success site through `SubmitForReviewAsync`.

View File

@@ -0,0 +1,116 @@
# Prime: recurring weekday schedule
**Date:** 2026-06-02
**Status:** Approved
## Problem
The Prime feature fires a single non-interactive "ping" prompt to warm up the
Claude usage window. Today a schedule is defined by a **date range**
(`StartDate`/`EndDate`) plus a `TimeOfDay` and a single `WorkdaysOnly` toggle.
This is awkward for the real use case: the user wants a *recurring* morning ping
on specific weekdays, not a bounded calendar window.
Desired behavior: pick the **days of the week** (e.g. MonFri) and a **time**.
The schedule recurs forever. Whenever the worker is running and it is one of the
selected days, the ping fires at (or shortly after) the chosen time. Concretely:
the worker autostarts on login, detects it is an eligible day around the target
time, and fires the ping.
## Decisions
- **Catch-up window:** unchanged. Keep the existing 30-minute catch-up — if the
worker boots within 30 min after the target time, the ping fires immediately;
otherwise it waits for the next eligible day. (User chose "keep current 30 min".)
- **Day picker UI:** seven compact **toggle buttons** in one row (Mo Tu We Th Fr
Sa Su), highlighted when selected — not labeled checkboxes.
## Design
### 1. Data model
`PrimeScheduleEntity` (`ClaudeDo.Data/Models`):
- **Remove:** `StartDate`, `EndDate`, `WorkdaysOnly`
- **Add:** `Days` — a `[Flags] enum PrimeDays` (`Monday=1, Tuesday=2, Wednesday=4,
Thursday=8, Friday=16, Saturday=32, Sunday=64`), stored as a single
`days_of_week INTEGER` column.
- **Keep:** `TimeOfDay`, `Enabled`, `LastRunAt`, `PromptOverride`, `CreatedAt`.
Rationale for a bitmask over a CSV string or 7 bool columns: one column, trivial
EF mapping (int), and a clean eligibility check.
`PrimeScheduleEntityConfiguration`: drop the `start_date`/`end_date`/
`workdays_only` property mappings; map `Days` to `days_of_week` (int, required,
default 31 = MonFri).
### 2. Scheduling logic — `NextDueCalculator`
- Drop all `StartDate`/`EndDate` gating (the `EndDate < today` early-out, the
`StartDate > today` clamps, and the bounds check in `IsEligibleDay`).
- `IsEligibleDay(s, d)` becomes: does `s.Days` contain the flag for
`d.DayOfWeek`? (Map `System.DayOfWeek` → `PrimeDays`.)
- The existing forward search (loops up to 8 days ahead) now simply walks to the
next selected weekday.
- `alreadyFiredToday` (compares `LastRunAt`'s local date to today) is unchanged.
- The 30-min catch-up (`FireImmediately`) is unchanged.
- A schedule with `Days == 0` (none selected) is never eligible. UI validation
prevents saving that state.
### 3. UI — `SettingsModalView.axaml` + `PrimeScheduleRowViewModel`
Row template changes:
- **Remove** the `ThemedDatePicker` (range) and the single "MonFri" checkbox.
- **Add** a horizontal row of 7 `ToggleButton`s (Mo Tu We Th Fr Sa Su), styled
to highlight when checked, bound to seven bool properties on the row VM.
- Keep the enabled checkbox, the time `TextBox`, the last-run label, and the
remove button.
`PrimeScheduleRowViewModel`:
- Replace `StartDate`/`EndDate`/`WorkdaysOnly` with seven `[ObservableProperty]`
bools: `Monday`…`Sunday`.
- Constructor decomposes `dto.Days` into the seven bools.
- `ToDto()` composes the seven bools back into the `Days` int.
`PrimeClaudeTabViewModel`:
- `AddSchedule` default: MonFri selected, time 07:00, enabled.
- `Validate`: replace the `StartDate > EndDate` check with "at least one day must
be selected"; keep the time-range (00:0023:59) check.
Update the explainer `TextBlock` text to describe weekday recurrence (keep the
"fires immediately if started within 30 minutes of the target time" note).
### 4. Migration
New EF Core migration in `ClaudeDo.Data/Migrations`:
- Add `days_of_week INTEGER NOT NULL DEFAULT 31`.
- Backfill from existing rows: `workdays_only = 1` → `31` (MonFri),
`workdays_only = 0` → `127` (all 7 days).
- Drop `start_date`, `end_date`, `workdays_only`.
- Update the model snapshot.
### 5. DTOs
Both copies of `PrimeScheduleDto` (Worker `ClaudeDo.Worker.Prime` and UI
`ClaudeDo.Ui.Services`) are passed over SignalR and must stay structurally
compatible. In both: remove `StartDate`, `EndDate`, `WorkdaysOnly`; add a single
`int Days` field (serializes cleanly as JSON; avoids sharing the enum across
projects). `PrimeScheduler.ToDto` maps `entity.Days` → `(int)`.
`PrimeScheduleRepository`: update `UpsertAsync` (copy `Days` instead of the three
removed fields) and `ListAsync` ordering (order by `TimeOfDay` instead of
`StartDate`).
### 6. Tests
- `NextDueCalculatorTests` — rewrite cases around weekday sets (e.g. MonFri
skips weekend; single-day schedule; catch-up still fires; already-fired-today
skips to next eligible day).
- `PrimeSchedulerTests` — update fixture DTOs to the new shape.
- `PrimeScheduleRepositoryTests` — update entity construction and assertions.
- `PrimeClaudeTabViewModelTests` — update for the day-bool VM and new validation.
## Out of scope
- Per-schedule catch-up tuning (rejected; fixed 30 min).
- Multiple times per day, timezones, or holiday calendars.

View File

@@ -4,11 +4,12 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration. - **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath (all nullable)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files - **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories ## Repositories

View File

@@ -3,6 +3,7 @@ using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
@@ -19,9 +20,24 @@ public class ClaudeDoDbContext : DbContext
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>(); public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>(); public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
private static readonly ValueConverter<DateTime?, DateTime?> UtcNullableConverter =
new(v => v, v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime) && property.GetValueConverter() == null)
property.SetValueConverter(UtcConverter);
else if (property.ClrType == typeof(DateTime?) && property.GetValueConverter() == null)
property.SetValueConverter(UtcNullableConverter);
}
} }
/// <summary> /// <summary>

View File

@@ -22,6 +22,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.DefaultPermissionMode) builder.Property(s => s.DefaultPermissionMode)
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions"); .HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
builder.Property(s => s.MaxParallelExecutions)
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
builder.Property(s => s.WorktreeStrategy) builder.Property(s => s.WorktreeStrategy)
.HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling"); .HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling");
builder.Property(s => s.CentralWorktreeRoot) builder.Property(s => s.CentralWorktreeRoot)

View File

@@ -16,6 +16,9 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
builder.HasOne(l => l.Config) builder.HasOne(l => l.Config)
.WithOne(c => c.List) .WithOne(c => c.List)

View File

@@ -13,10 +13,9 @@ public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeSc
builder.HasKey(s => s.Id); builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever(); builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired(); builder.Property(s => s.Days).HasColumnName("days_of_week")
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired(); .IsRequired().HasDefaultValue(PrimeDays.Weekdays);
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired(); builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.WorkdaysOnly).HasColumnName("workdays_only").IsRequired().HasDefaultValue(true);
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true); builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
builder.Property(s => s.LastRunAt).HasColumnName("last_run_at"); builder.Property(s => s.LastRunAt).HasColumnName("last_run_at");
builder.Property(s => s.PromptOverride).HasColumnName("prompt_override"); builder.Property(s => s.PromptOverride).HasColumnName("prompt_override");

View File

@@ -14,6 +14,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
TaskStatus.Idle => "idle", TaskStatus.Idle => "idle",
TaskStatus.Queued => "queued", TaskStatus.Queued => "queued",
TaskStatus.Running => "running", TaskStatus.Running => "running",
TaskStatus.WaitingForReview => "waiting_for_review",
TaskStatus.Done => "done", TaskStatus.Done => "done",
TaskStatus.Failed => "failed", TaskStatus.Failed => "failed",
TaskStatus.Cancelled => "cancelled", TaskStatus.Cancelled => "cancelled",
@@ -26,6 +27,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
"idle" => TaskStatus.Idle, "idle" => TaskStatus.Idle,
"queued" => TaskStatus.Queued, "queued" => TaskStatus.Queued,
"running" => TaskStatus.Running, "running" => TaskStatus.Running,
"waiting_for_review" => TaskStatus.WaitingForReview,
"done" => TaskStatus.Done, "done" => TaskStatus.Done,
"failed" => TaskStatus.Failed, "failed" => TaskStatus.Failed,
"cancelled" => TaskStatus.Cancelled, "cancelled" => TaskStatus.Cancelled,
@@ -72,6 +74,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id"); builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
builder.Property(t => t.LogPath).HasColumnName("log_path"); builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at"); builder.Property(t => t.StartedAt).HasColumnName("started_at");

View File

@@ -0,0 +1,600 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601114247_AddListSortOrder")]
partial class AddListSortOrder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListSortOrder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "sort_order",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: 0);
// Backfill existing rows with a dense order (0..N-1) by creation time
// so today's sidebar order is preserved after the migration.
migrationBuilder.Sql("""
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at) - 1) AS rn
FROM lists
)
UPDATE lists SET sort_order = (SELECT rn FROM ordered WHERE ordered.id = lists.id);
""");
migrationBuilder.CreateIndex(
name: "idx_lists_sort",
table: "lists",
column: "sort_order");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_lists_sort",
table: "lists");
migrationBuilder.DropColumn(
name: "sort_order",
table: "lists");
}
}
}

View File

@@ -0,0 +1,607 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601133737_AddMaxParallelExecutions")]
partial class AddMaxParallelExecutions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMaxParallelExecutions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "max_parallel_executions",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 1);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "max_parallel_executions",
value: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "max_parallel_executions",
table: "app_settings");
}
}
}

View File

@@ -0,0 +1,607 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601140000_NormalizeListIdFormat")]
partial class NormalizeListIdFormat
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class NormalizeListIdFormat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// SQLite: PRAGMA foreign_keys must run outside a transaction.
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;", suppressTransaction: true);
// Normalize tasks.list_id: 32-char compact hex → 36-char dashed UUID
migrationBuilder.Sql("""
UPDATE tasks
SET list_id = substr(list_id,1,8)||'-'||substr(list_id,9,4)||'-'||substr(list_id,13,4)||'-'||substr(list_id,17,4)||'-'||substr(list_id,21,12)
WHERE length(list_id) = 32;
""");
// Normalize list_config.list_id (also the PK of that table)
migrationBuilder.Sql("""
UPDATE list_config
SET list_id = substr(list_id,1,8)||'-'||substr(list_id,9,4)||'-'||substr(list_id,13,4)||'-'||substr(list_id,17,4)||'-'||substr(list_id,21,12)
WHERE length(list_id) = 32;
""");
// Normalize lists.id (PK — must come last)
migrationBuilder.Sql("""
UPDATE lists
SET id = substr(id,1,8)||'-'||substr(id,9,4)||'-'||substr(id,13,4)||'-'||substr(id,17,4)||'-'||substr(id,21,12)
WHERE length(id) = 32;
""");
migrationBuilder.Sql("PRAGMA foreign_keys = ON;", suppressTransaction: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("PRAGMA foreign_keys = OFF;", suppressTransaction: true);
migrationBuilder.Sql("UPDATE tasks SET list_id = replace(list_id,'-','') WHERE length(list_id) = 36;");
migrationBuilder.Sql("UPDATE list_config SET list_id = replace(list_id,'-','') WHERE length(list_id) = 36;");
migrationBuilder.Sql("UPDATE lists SET id = replace(id,'-','') WHERE length(id) = 36;");
migrationBuilder.Sql("PRAGMA foreign_keys = ON;", suppressTransaction: true);
}
}
}

View File

@@ -0,0 +1,611 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260601150820_AddReviewFeedback")]
partial class AddReviewFeedback
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddReviewFeedback : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "review_feedback",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "review_feedback",
table: "tasks");
}
}
}

View File

@@ -0,0 +1,603 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260602060000_PrimeWeekdays")]
partial class PrimeWeekdays
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class PrimeWeekdays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "days_of_week",
table: "prime_schedules",
type: "INTEGER",
nullable: false,
defaultValue: 31);
migrationBuilder.Sql(
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "start_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
migrationBuilder.AddColumn<DateOnly>(
name: "end_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
migrationBuilder.AddColumn<bool>(
name: "workdays_only", table: "prime_schedules",
type: "INTEGER", nullable: false, defaultValue: true);
migrationBuilder.Sql(
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
}
}
}

View File

@@ -54,6 +54,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions") .HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode"); .HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders") b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("repo_import_folders"); .HasColumnName("repo_import_folders");
@@ -89,6 +95,7 @@ namespace ClaudeDo.Data.Migrations
DefaultMaxTurns = 100, DefaultMaxTurns = 100,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "auto", DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false, WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling" WorktreeStrategy = "sibling"
@@ -140,12 +147,21 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("name"); .HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir") b.Property<string>("WorkingDir")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("working_dir"); .HasColumnName("working_dir");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null); b.ToTable("lists", (string)null);
}); });
@@ -159,16 +175,18 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("created_at"); .HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled") b.Property<bool>("Enabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(true) .HasDefaultValue(true)
.HasColumnName("enabled"); .HasColumnName("enabled");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTimeOffset?>("LastRunAt") b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("last_run_at"); .HasColumnName("last_run_at");
@@ -177,20 +195,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("prompt_override"); .HasColumnName("prompt_override");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.Property<TimeSpan>("TimeOfDay") b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("time_of_day"); .HasColumnName("time_of_day");
b.Property<bool>("WorkdaysOnly")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("workdays_only");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("prime_schedules", (string)null); b.ToTable("prime_schedules", (string)null);
@@ -327,6 +335,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("result"); .HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<DateTime?>("ScheduledFor") b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("scheduled_for"); .HasColumnName("scheduled_for");

View File

@@ -11,6 +11,8 @@ public sealed class AppSettingsEntity
public int DefaultMaxTurns { get; set; } = 100; public int DefaultMaxTurns { get; set; } = 100;
public string DefaultPermissionMode { get; set; } = "auto"; public string DefaultPermissionMode { get; set; } = "auto";
public int MaxParallelExecutions { get; set; } = 1;
public string WorktreeStrategy { get; set; } = "sibling"; public string WorktreeStrategy { get; set; } = "sibling";
public string? CentralWorktreeRoot { get; set; } public string? CentralWorktreeRoot { get; set; }
public bool WorktreeAutoCleanupEnabled { get; set; } public bool WorktreeAutoCleanupEnabled { get; set; }

View File

@@ -7,6 +7,7 @@ public sealed class ListEntity
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType; public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
public int SortOrder { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }

View File

@@ -0,0 +1,16 @@
namespace ClaudeDo.Data.Models;
[Flags]
public enum PrimeDays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
All = Weekdays | Saturday | Sunday, // 127
}

View File

@@ -3,10 +3,8 @@ namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity public sealed class PrimeScheduleEntity
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public DateOnly StartDate { get; set; } public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
public DateOnly EndDate { get; set; }
public TimeSpan TimeOfDay { get; set; } public TimeSpan TimeOfDay { get; set; }
public bool WorkdaysOnly { get; set; } = true;
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; } public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; } public string? PromptOverride { get; set; }

View File

@@ -5,6 +5,7 @@ public enum TaskStatus
Idle, Idle,
Queued, Queued,
Running, Running,
WaitingForReview,
Done, Done,
Failed, Failed,
Cancelled, Cancelled,
@@ -28,6 +29,7 @@ public sealed class TaskEntity
public string? BlockedByTaskId { get; set; } public string? BlockedByTaskId { get; set; }
public DateTime? ScheduledFor { get; set; } public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; } public string? Result { get; set; }
public string? ReviewFeedback { get; set; }
public string? LogPath { get; set; } public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }

View File

@@ -44,6 +44,7 @@ public sealed class AppSettingsRepository
row.DefaultMaxTurns = updated.DefaultMaxTurns; row.DefaultMaxTurns = updated.DefaultMaxTurns;
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode) row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
? "auto" : updated.DefaultPermissionMode; ? "auto" : updated.DefaultPermissionMode;
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
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;

View File

@@ -33,7 +33,19 @@ public sealed class ListRepository
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{ {
return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
}
public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default)
{
var idSet = orderedListIds.ToHashSet();
var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct);
for (int i = 0; i < orderedListIds.Count; i++)
{
var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]);
if (e is not null) e.SortOrder = i;
}
await _context.SaveChangesAsync(ct);
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)

View File

@@ -12,10 +12,8 @@ public sealed class PrimeScheduleRepository
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default) public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{ {
var rows = await _context.PrimeSchedules.AsNoTracking() var rows = await _context.PrimeSchedules.AsNoTracking().ToListAsync(ct);
.OrderBy(s => s.StartDate) return rows.OrderBy(s => s.TimeOfDay).ToList();
.ToListAsync(ct);
return rows.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay).ToList();
} }
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) => public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
@@ -30,10 +28,8 @@ public sealed class PrimeScheduleRepository
} }
else else
{ {
existing.StartDate = entity.StartDate; existing.Days = entity.Days;
existing.EndDate = entity.EndDate;
existing.TimeOfDay = entity.TimeOfDay; existing.TimeOfDay = entity.TimeOfDay;
existing.WorkdaysOnly = entity.WorkdaysOnly;
existing.Enabled = entity.Enabled; existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride; existing.PromptOverride = entity.PromptOverride;
} }

View File

@@ -15,7 +15,7 @@ public static class DefaultListsSeeder
{ {
ctx.Lists.Add(new ListEntity ctx.Lists.Add(new ListEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString(),
Name = name, Name = name,
CreatedAt = now, CreatedAt = now,
}); });

View File

@@ -209,6 +209,8 @@ public partial class App : Application
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>(); sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>(); sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<RegisterMcpStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterMcpStep>());
sc.AddSingleton<RegisterAutostartStep>(); sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>()); sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>(); sc.AddSingleton<IInstallStep, CreateShortcutsStep>();

View File

@@ -32,4 +32,8 @@ public sealed class InstallContext
// InstallPage // InstallPage
public bool CreateDesktopShortcut { get; set; } = true; public bool CreateDesktopShortcut { get; set; } = true;
// WelcomePage — register the external MCP endpoint with the Claude CLI.
public bool RegisterMcpWithClaude { get; set; } = true;
public int ExternalMcpPort { get; set; } = 47_822;
} }

View File

@@ -148,6 +148,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(), _serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
// Migrates the legacy service away and (re)registers the logon task. // Migrates the legacy service away and (re)registers the logon task.
_serviceProvider.GetRequiredService<RegisterAutostartStep>(), _serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<RegisterMcpStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(), _serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(), _serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a // Refresh the bundled uninstaller exe + Add/Remove-Programs version so a

View File

@@ -32,6 +32,14 @@
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11" <TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/> Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
<CheckBox Content="Register MCP server with Claude"
IsChecked="{Binding RegisterMcp}"
Margin="0,24,0,0"/>
<TextBlock Text="Runs 'claude mcp add' so Claude can view and manage your ClaudeDo tasks. You can change this later."
TextWrapping="Wrap" FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View File

@@ -24,6 +24,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
[ObservableProperty] private string _heading = "Install ClaudeDo"; [ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue."; [ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true; [ObservableProperty] private bool _installDirEditable = true;
[ObservableProperty] private bool _registerMcp = true;
public WelcomePageViewModel(InstallContext context) public WelcomePageViewModel(InstallContext context)
{ {
@@ -62,6 +63,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
public Task ApplyAsync() public Task ApplyAsync()
{ {
_context.InstallDirectory = InstallDirectory; _context.InstallDirectory = InstallDirectory;
_context.RegisterMcpWithClaude = RegisterMcp;
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -0,0 +1,47 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterMcpStep : IInstallStep
{
private const string ServerName = "claudedo";
public string Name => "Register MCP with Claude";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (!ctx.RegisterMcpWithClaude)
{
progress.Report("Skipped (not selected)");
return StepResult.Ok();
}
var url = $"http://127.0.0.1:{ctx.ExternalMcpPort}/mcp";
// Drop any prior registration first so a re-run (e.g. update, changed port)
// overwrites cleanly instead of erroring on a duplicate name.
progress.Report($"Removing existing '{ServerName}' MCP registration (if any)...");
await ProcessRunner.RunAsync(ctx.ClaudeBin, $"mcp remove --scope user {ServerName}", null, progress, ct);
progress.Report($"Registering '{ServerName}' MCP server at {url}...");
var (exit, output) = await ProcessRunner.RunAsync(
ctx.ClaudeBin,
$"mcp add --transport http --scope user {ServerName} {url}",
null, progress, ct);
// Non-fatal: a missing/old Claude CLI must never block the install. Surface the
// manual command so the user can register it themselves later.
if (exit != 0)
{
progress.Report(
$"Could not register MCP automatically (claude exited {exit}). " +
$"Run manually: claude mcp add --transport http --scope user {ServerName} {url}");
}
else
{
progress.Report("MCP server registered with Claude.");
}
return StepResult.Ok();
}
}

View File

@@ -15,6 +15,8 @@ public class StatusColorConverter : IValueConverter
{ {
"queued" => Brushes.DodgerBlue, "queued" => Brushes.DodgerBlue,
"running" => Brushes.Orange, "running" => Brushes.Orange,
"waitingforreview" => Brushes.MediumPurple,
"waiting_for_review" => Brushes.MediumPurple,
"done" => Brushes.Green, "done" => Brushes.Green,
"failed" => Brushes.Red, "failed" => Brushes.Red,
"manual" => Brushes.Gray, "manual" => Brushes.Gray,

View File

@@ -1,21 +0,0 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace ClaudeDo.Ui.Converters;
public sealed class TimeSpanToHhmmConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is TimeSpan t ? $"{t.Hours:00}:{t.Minutes:00}" : "07:00";
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string s) return new TimeSpan(7, 0, 0);
var parts = s.Split(':');
if (parts.Length == 2 &&
int.TryParse(parts[0], out var h) && h is >= 0 and <= 23 &&
int.TryParse(parts[1], out var m) && m is >= 0 and <= 59)
return new TimeSpan(h, m, 0);
return new TimeSpan(7, 0, 0);
}
}

View File

@@ -21,6 +21,7 @@
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) --> <!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry>
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry> <StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
<!-- Brand check glyph — filled rounded square with inset tick --> <!-- Brand check glyph — filled rounded square with inset tick -->
<StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry> <StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry>
@@ -184,6 +185,15 @@
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" /> <Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
</Style> </Style>
<!-- done → green (#6FA86B) -->
<Style Selector="Border.chip.done">
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource DoneTintBorderBrush}" />
</Style>
<Style Selector="Border.chip.done > TextBlock">
<Setter Property="Foreground" Value="{StaticResource StatusDoneBrush}" />
</Style>
<!-- idle → TextMute (#6B7973) --> <!-- idle → TextMute (#6B7973) -->
<Style Selector="Border.chip.idle"> <Style Selector="Border.chip.idle">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" /> <Setter Property="Background" Value="{StaticResource Surface2Brush}" />
@@ -826,6 +836,15 @@
<Setter Property="Opacity" Value="0.5" /> <Setter Property="Opacity" Value="0.5" />
<Setter Property="TextDecorations" Value="Strikethrough" /> <Setter Property="TextDecorations" Value="Strikethrough" />
</Style> </Style>
<Style Selector="TextBox.subtask-edit">
<Setter Property="Padding" Value="4,2" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- SECTION LABELS (OVERDUE / TASKS / COMPLETED) --> <!-- SECTION LABELS (OVERDUE / TASKS / COMPLETED) -->
@@ -1016,4 +1035,23 @@
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
</Style> </Style>
<!-- ============================================================ -->
<!-- DAY TOGGLE -->
<!-- Small ToggleButton for weekday pickers (Prime schedule row) -->
<!-- ============================================================ -->
<Style Selector="ToggleButton.day-toggle">
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style>
</Styles> </Styles>

View File

@@ -83,6 +83,7 @@
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" /> <SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" /> <SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" /> <SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
<SolidColorBrush x:Key="StatusDoneBrush" Color="#6FA86B" />
<!-- Subtle white overlay (island hairline border) --> <!-- Subtle white overlay (island hairline border) -->
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" /> <SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
@@ -96,6 +97,8 @@
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" /> <SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" /> <SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" /> <SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) --> <!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%"> <LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">

View File

@@ -33,6 +33,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status); Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task ApproveReviewAsync(string taskId);
Task RejectReviewToQueueAsync(string taskId, string feedback);
Task RejectReviewToIdleAsync(string taskId);
Task CancelReviewAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(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);

View File

@@ -2,10 +2,8 @@ namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto( public sealed record PrimeScheduleDto(
Guid Id, Guid Id,
DateOnly StartDate, int Days,
DateOnly EndDate,
TimeSpan TimeOfDay, TimeSpan TimeOfDay,
bool WorkdaysOnly,
bool Enabled, bool Enabled,
DateTimeOffset? LastRunAt, DateTimeOffset? LastRunAt,
string? PromptOverride); string? PromptOverride);

View File

@@ -349,6 +349,26 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public async Task ApproveReviewAsync(string taskId)
{
await _hub.InvokeAsync("ApproveReview", taskId);
}
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
{
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
}
public async Task RejectReviewToIdleAsync(string taskId)
{
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
}
public async Task CancelReviewAsync(string taskId)
{
await _hub.InvokeAsync("CancelReview", taskId);
}
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId); => TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
@@ -450,6 +470,7 @@ public sealed record AppSettingsDto(
string DefaultModel, string DefaultModel,
int DefaultMaxTurns, int DefaultMaxTurns,
string DefaultPermissionMode, string DefaultPermissionMode,
int MaxParallelExecutions,
string WorktreeStrategy, string WorktreeStrategy,
string? CentralWorktreeRoot, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, bool WorktreeAutoCleanupEnabled,

View File

@@ -840,6 +840,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
CloseDetail?.Invoke(); CloseDetail?.Invoke();
} }
[RelayCommand]
private async System.Threading.Tasks.Task CommitSubtaskEditAsync(SubtaskRowViewModel? row)
{
if (row is null || !row.IsEditing) return;
row.IsEditing = false;
var title = row.Title?.Trim() ?? "";
await using var ctx = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(ctx);
// Emptying the text removes the step.
if (string.IsNullOrEmpty(title))
{
await repo.DeleteAsync(row.Id);
Subtasks.Remove(row);
return;
}
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
var entity = subs.FirstOrDefault(s => s.Id == row.Id);
if (entity is null) return;
if (entity.Title != title)
{
entity.Title = title;
await repo.UpdateAsync(entity);
}
row.Title = title;
}
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task AddSubtaskAsync() private async System.Threading.Tasks.Task AddSubtaskAsync()
{ {
@@ -943,6 +972,7 @@ 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 bool _isEditing;
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status; [ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active; [ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
} }

View File

@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir; [ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public string? IconKey { get; init; } public string? IconKey { get; init; }
public string? DotColorKey { get; init; } public string? DotColorKey { get; init; }
} }

View File

@@ -82,6 +82,52 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
finally { _worktreesOverviewOpen = false; } finally { _worktreesOverviewOpen = false; }
} }
[RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
[RelayCommand]
private void OpenInTerminal(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
Arguments = $"-d \"{dir}\"",
UseShellExecute = true,
});
}
catch
{
// Windows Terminal not installed — fall back to a plain console at the directory.
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
WorkingDirectory = dir,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
}
public ObservableCollection<ListNavItemViewModel> Items { get; } = new(); public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
@@ -231,6 +277,57 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
} }
} }
public void ClearDropHints()
{
foreach (var r in UserLists)
{
r.DropHintAbove = false;
r.DropHintBelow = false;
}
}
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
{
foreach (var r in UserLists)
{
var isTarget = ReferenceEquals(r, target);
r.DropHintAbove = isTarget && !placeBelow;
r.DropHintBelow = isTarget && placeBelow;
}
}
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
{
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return;
MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
}
private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll,
ListNavItemViewModel source,
ListNavItemViewModel target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
var tgtIdx = coll.IndexOf(target);
if (srcIdx < 0 || tgtIdx < 0 || srcIdx == tgtIdx) return;
var finalIdx = placeBelow ? tgtIdx + 1 : tgtIdx;
if (srcIdx < finalIdx) finalIdx--;
if (finalIdx < 0) finalIdx = 0;
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx);
}
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);

View File

@@ -17,7 +17,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private PlanningPhase _planningPhase; [ObservableProperty] private PlanningPhase _planningPhase;
[ObservableProperty] private string? _branch; [ObservableProperty] private string? _branch;
[ObservableProperty] private string? _diffStat; [ObservableProperty] private string? _diffStat;
[ObservableProperty] private string? _liveTail;
[ObservableProperty] private DateTime? _scheduledFor; [ObservableProperty] private DateTime? _scheduledFor;
[ObservableProperty] private int _diffAdditions; [ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions; [ObservableProperty] private int _diffDeletions;
@@ -64,27 +63,30 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool HasSteps => StepsCount > 0; public bool HasSteps => StepsCount > 0;
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 IsWaitingForReview => Status == TaskStatus.WaitingForReview;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized); && (!IsChild || ParentFinalized);
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned). // Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
public bool CanQueuePlan => !IsChild && HasPlanningChildren public bool CanQueuePlan => !IsChild && HasPlanningChildren
&& PlanningPhase == PlanningPhase.Finalized && PlanningPhase == PlanningPhase.Finalized
&& !HasQueuedSubtasks; && !HasQueuedSubtasks;
public bool HasSchedule => ScheduledFor.HasValue; public bool HasSchedule => ScheduledFor.HasValue;
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
public string DiffAdditionsText => $"+{DiffAdditions}"; public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"{DiffDeletions}"; public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => $"{StepsCompleted}/{StepsCount} steps"; public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
public string StatusLabel => Status == TaskStatus.WaitingForReview ? "Waiting for Review" : Status.ToString();
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{ {
(TaskStatus.Running, _) => "running", (TaskStatus.Running, _) => "running",
(TaskStatus.WaitingForReview, _) => "review",
(TaskStatus.Failed, _) => "error", (TaskStatus.Failed, _) => "error",
(TaskStatus.Done, _) => "review", (TaskStatus.Done, _) => "done",
(TaskStatus.Queued, true) => "waiting", (TaskStatus.Queued, true) => "waiting",
(TaskStatus.Queued, false) => "queued", (TaskStatus.Queued, false) => "queued",
_ => "idle", _ => "idle",
@@ -93,10 +95,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnStatusChanged(TaskStatus value) partial void OnStatusChanged(TaskStatus value)
{ {
OnPropertyChanged(nameof(StatusChipClass)); OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(IsQueued)); OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting)); OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasLiveTail));
OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned)); OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession)); OnPropertyChanged(nameof(CanOpenPlanningSession));
@@ -152,7 +155,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
} }
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 OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue)); partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnScheduledForChanged(DateTime? value) partial void OnScheduledForChanged(DateTime? value)
{ {

View File

@@ -56,18 +56,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated; _worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated; _worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.ListUpdatedEvent += OnWorkerListUpdated; _worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList); _worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
} }
} }
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 OnWorkerListUpdated(string listId) private async void OnWorkerListUpdated(string listId)
{ {
// Mirror the renamed list onto every task row that references it, // Mirror the renamed list onto every task row that references it,
@@ -487,6 +480,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);
} }
[RelayCommand]
private async Task ClearCompletedAsync()
{
if (CompletedItems.Count == 0) return;
// Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children.
var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
if (!ok) return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
{
try
{
await repo.DeleteAsync(row.Id);
Items.Remove(row);
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand] [RelayCommand]
private async Task ToggleStarAsync(TaskRowViewModel row) private async Task ToggleStarAsync(TaskRowViewModel row)
{ {
@@ -577,6 +602,42 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
catch { /* worker offline; the broadcast will reconcile when it returns */ } catch { /* worker offline; the broadcast will reconcile when it returns */ }
} }
// ── Review actions (visible when a task is WaitingForReview) ─────────────
// Each delegates to the worker hub, which performs the transition and
// broadcasts TaskUpdated; the row refreshes from that broadcast.
[RelayCommand]
private async Task ApproveReviewAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.ApproveReviewAsync(row.Id); }
catch { /* offline; broadcast reconciles on return */ }
}
public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
{
if (!row.IsWaitingForReview || _worker is null) return;
if (string.IsNullOrWhiteSpace(feedback)) return;
try { await _worker.RejectReviewToQueueAsync(row.Id, feedback); }
catch { /* offline; broadcast reconciles on return */ }
}
[RelayCommand]
private async Task RejectReviewToIdleAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.RejectReviewToIdleAsync(row.Id); }
catch { /* offline; broadcast reconciles on return */ }
}
[RelayCommand]
private async Task CancelReviewAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.CancelReviewAsync(row.Id); }
catch { /* offline; broadcast reconciles on return */ }
}
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when) public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
{ {
if (row is null) return; if (row is null) return;

View File

@@ -9,6 +9,7 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias; [ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
[ObservableProperty] private int _defaultMaxTurns = 100; [ObservableProperty] private int _defaultMaxTurns = 100;
[ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode; [ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode;
[ObservableProperty] private int _maxParallelExecutions = 1;
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases; public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes; public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
@@ -17,6 +18,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
{ {
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200) if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
return "Max turns must be between 1 and 200."; return "Max turns must be between 1 and 200.";
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20)
return "Max parallel executions must be between 1 and 20.";
return null; return null;
} }
} }

View File

@@ -30,8 +30,8 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
{ {
foreach (var r in Rows) foreach (var r in Rows)
{ {
if (r.StartDate > r.EndDate) if (r.DaysMask() == 0)
return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date."; return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1)) if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59."; return "Time must be between 00:00 and 23:59.";
} }
@@ -52,13 +52,10 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void AddSchedule() private void AddSchedule()
{ {
var today = DateOnly.FromDateTime(DateTime.Today);
var dto = new PrimeScheduleDto( var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(), Id: Guid.NewGuid(),
StartDate: today, Days: 31, // MonFri
EndDate: today.AddDays(30),
TimeOfDay: new TimeSpan(7, 0, 0), TimeOfDay: new TimeSpan(7, 0, 0),
WorkdaysOnly: true,
Enabled: true, Enabled: true,
LastRunAt: null, LastRunAt: null,
PromptOverride: null); PromptOverride: null);

View File

@@ -5,14 +5,20 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{ {
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
public Guid Id { get; } public Guid Id { get; }
public bool IsExisting { get; } public bool IsExisting { get; }
[ObservableProperty] private bool _enabled; [ObservableProperty] private bool _enabled;
[ObservableProperty] private DateOnly _startDate; [ObservableProperty] private bool _monday;
[ObservableProperty] private DateOnly _endDate; [ObservableProperty] private bool _tuesday;
[ObservableProperty] private bool _wednesday;
[ObservableProperty] private bool _thursday;
[ObservableProperty] private bool _friday;
[ObservableProperty] private bool _saturday;
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay; [ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private bool _workdaysOnly;
[ObservableProperty] private DateTimeOffset? _lastRunAt; [ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—"; public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
@@ -24,13 +30,30 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
Id = dto.Id; Id = dto.Id;
IsExisting = isExisting; IsExisting = isExisting;
Enabled = dto.Enabled; Enabled = dto.Enabled;
StartDate = dto.StartDate; Monday = (dto.Days & Mon) != 0;
EndDate = dto.EndDate; Tuesday = (dto.Days & Tue) != 0;
Wednesday = (dto.Days & Wed) != 0;
Thursday = (dto.Days & Thu) != 0;
Friday = (dto.Days & Fri) != 0;
Saturday = (dto.Days & Sat) != 0;
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay; TimeOfDay = dto.TimeOfDay;
WorkdaysOnly = dto.WorkdaysOnly;
LastRunAt = dto.LastRunAt; LastRunAt = dto.LastRunAt;
} }
public int DaysMask()
{
int m = 0;
if (Monday) m |= Mon;
if (Tuesday) m |= Tue;
if (Wednesday) m |= Wed;
if (Thursday) m |= Thu;
if (Friday) m |= Fri;
if (Saturday) m |= Sat;
if (Sunday) m |= Sun;
return m;
}
public PrimeScheduleDto ToDto() => public PrimeScheduleDto ToDto() =>
new(Id, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null); new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
} }

View File

@@ -42,6 +42,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.DefaultModel = dto.DefaultModel ?? "sonnet"; General.DefaultModel = dto.DefaultModel ?? "sonnet";
General.DefaultMaxTurns = dto.DefaultMaxTurns; General.DefaultMaxTurns = dto.DefaultMaxTurns;
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto"; General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
General.MaxParallelExecutions = dto.MaxParallelExecutions;
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling"; Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot; Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled; Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -69,6 +70,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.DefaultModel ?? "sonnet", General.DefaultModel ?? "sonnet",
General.DefaultMaxTurns, General.DefaultMaxTurns,
General.DefaultPermissionMode ?? "auto", General.DefaultPermissionMode ?? "auto",
General.MaxParallelExecutions,
Worktrees.WorktreeStrategy ?? "sibling", Worktrees.WorktreeStrategy ?? "sibling",
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot, string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
Worktrees.WorktreeAutoCleanupEnabled, Worktrees.WorktreeAutoCleanupEnabled,

View File

@@ -1,4 +1,3 @@
using System;
using System.Windows.Input; using System.Windows.Input;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
@@ -23,16 +22,49 @@ public class ModalShell : ContentControl
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); } public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); } public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
private Window? _window;
private PixelPoint _dragStartScreen;
private PixelPoint _dragStartPos;
private bool _dragging;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{ {
base.OnApplyTemplate(e); base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar) if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
{
bar.PointerPressed += OnTitleBarPressed; bar.PointerPressed += OnTitleBarPressed;
bar.PointerMoved += OnTitleBarMoved;
bar.PointerReleased += OnTitleBarReleased;
}
} }
// VisualRoot is a TopLevelHost (not the Window) in Avalonia 12, so resolve the
// owning Window via TopLevel.GetTopLevel and drive the move manually — BeginMoveDrag
// and a VisualRoot-as-Window cast both fail here.
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{ {
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && VisualRoot is Window w) if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
w.BeginMoveDrag(e); _window = TopLevel.GetTopLevel(this) as Window;
if (_window is null) return;
_dragStartScreen = _window.PointToScreen(e.GetPosition(_window));
_dragStartPos = _window.Position;
_dragging = true;
e.Pointer.Capture(sender as IInputElement);
}
private void OnTitleBarMoved(object? sender, PointerEventArgs e)
{
if (!_dragging || _window is null
|| !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var cur = _window.PointToScreen(e.GetPosition(_window));
_window.Position = new PixelPoint(
_dragStartPos.X + (cur.X - _dragStartScreen.X),
_dragStartPos.Y + (cur.Y - _dragStartScreen.Y));
}
private void OnTitleBarReleased(object? sender, PointerReleasedEventArgs e)
{
_dragging = false;
e.Pointer.Capture(null);
} }
} }

View File

@@ -50,7 +50,10 @@
<StackPanel Grid.Column="1" Spacing="0"> <StackPanel Grid.Column="1" Spacing="0">
<TextBlock Classes="meta" <TextBlock Classes="meta"
Text="{Binding TaskIdBadge}" Text="{Binding TaskIdBadge}"
Margin="0,0,0,4"/> Margin="0,0,0,4"
Cursor="Hand"
ToolTip.Tip="Copy task ID"
Tapped="OnTaskIdTapped"/>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}" <TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium" FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
BorderThickness="0" Background="Transparent" BorderThickness="0" Background="Transparent"
@@ -186,13 +189,30 @@
Width="16" Height="16" Width="16" Height="16"
Cursor="Hand"/> Cursor="Hand"/>
</Button> </Button>
<TextBlock Grid.Column="1" <Panel Grid.Column="1" VerticalAlignment="Center">
Classes="subtask-title" <TextBlock Classes="subtask-title"
Text="{Binding Title}" Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
FontSize="{StaticResource FontSizeBody}" FontSize="{StaticResource FontSizeBody}"
Foreground="{DynamicResource TextDimBrush}" Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap"/> TextWrapping="Wrap"
Cursor="Ibeam"
Tapped="OnSubtaskTitleTapped"/>
<TextBox Classes="subtask-edit"
Text="{Binding Title, Mode=TwoWay}"
IsVisible="{Binding IsEditing}"
FontSize="{StaticResource FontSizeBody}"
AcceptsReturn="False"
TextWrapping="Wrap"
LostFocus="OnSubtaskEditLostFocus">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
CommandParameter="{Binding}"/>
</TextBox.KeyBindings>
</TextBox>
</Panel>
</Grid> </Grid>
</Border> </Border>
</DataTemplate> </DataTemplate>

View File

@@ -1,9 +1,13 @@
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform; using Avalonia.Input.Platform;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
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; using ClaudeDo.Ui.Views.Planning;
@@ -135,6 +139,31 @@ public partial class DetailsIslandView : UserControl
return await tcs.Task; return await tcs.Task;
} }
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
{
if (sender is not Control c || c.DataContext is not SubtaskRowViewModel row) return;
row.IsEditing = true;
var box = (c.GetVisualParent() as Panel)?.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
if (box is not null)
Dispatcher.UIThread.Post(() => { box.Focus(); box.SelectAll(); }, DispatcherPriority.Background);
}
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is DetailsIslandViewModel vm
&& sender is Control c && c.DataContext is SubtaskRowViewModel row)
vm.CommitSubtaskEditCommand.Execute(row);
}
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.Task.Id);
}
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e) private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not DetailsIslandViewModel vm) return; if (DataContext is not DetailsIslandViewModel vm) return;

View File

@@ -113,8 +113,18 @@
<ItemsControl ItemsSource="{Binding UserLists}"> <ItemsControl ItemsSource="{Binding UserLists}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel"> <DataTemplate DataType="vm:ListNavItemViewModel">
<Border Classes="list-item" Classes.active="{Binding IsActive}" <Grid RowDefinitions="Auto,Auto,Auto">
Tapped="OnItemTapped">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu> <Border.ContextMenu>
<ContextMenu> <ContextMenu>
<MenuItem Header="Settings..." <MenuItem Header="Settings..."
@@ -123,6 +133,15 @@
<MenuItem Header="Worktrees…" <MenuItem Header="Worktrees…"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="Open in Explorer"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="Open in Terminal"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto"> <Grid ColumnDefinitions="20,*,Auto">
@@ -152,6 +171,12 @@
Text="{Binding Count}"/> Text="{Binding Count}"/>
</Grid> </Grid>
</Border> </Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>

View File

@@ -1,9 +1,11 @@
using System.Linq; using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
@@ -13,9 +15,13 @@ namespace ClaudeDo.Ui.Views.Islands;
public partial class ListsIslandView : UserControl public partial class ListsIslandView : UserControl
{ {
private static readonly DataFormat<string> ListRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-list-row");
public ListsIslandView() public ListsIslandView()
{ {
InitializeComponent(); InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
DataContextChanged += (_, _) => DataContextChanged += (_, _) =>
{ {
if (DataContext is ListsIslandViewModel vm) if (DataContext is ListsIslandViewModel vm)
@@ -84,6 +90,127 @@ public partial class ListsIslandView : UserControl
vm.SelectCommand.Execute(item); vm.SelectCommand.Execute(item);
} }
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
if (e.Source is not Visual src) return;
var border = FindListItemBorder(src);
if (border?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return;
// Double-click opens the list's settings instead of starting a drag. Handled here
// because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event.
if (e.ClickCount == 2)
{
vm.OpenListSettingsCommand.Execute(row);
return;
}
// Select now so the right pane updates whether the gesture becomes a click or a drag
// (the Tapped handler doesn't fire once DoDragDropAsync captures the pointer).
vm.SelectCommand.Execute(row);
var data = new DataTransfer();
data.Add(DataTransferItem.Create(ListRowFormat, row.Id));
try
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
private void OnListDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(ListRowFormat) ?? true)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
ListNavItemViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextUserList(vm, target);
if (next is not null) { hintRow = next; hintBelow = false; }
else { hintRow = target; hintBelow = true; }
}
if (hintRow.Id == sourceId)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetDropHint(hintRow, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
private async void OnListDrop(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return;
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
var source = vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
if (source is null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private static Border? FindListItemBorder(Visual? v)
{
while (v is not null)
{
if (v is Border b && b.Classes.Contains("list-item")) return b;
v = v.GetVisualParent();
}
return null;
}
private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row)
{
var idx = vm.UserLists.IndexOf(row);
if (idx < 0) return null;
return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null;
}
private async System.Threading.Tasks.Task ShowSettingsAsync(SettingsModalViewModel settingsVm) private async System.Threading.Tasks.Task ShowSettingsAsync(SettingsModalViewModel settingsVm)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;

View File

@@ -1,6 +1,5 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
@@ -9,16 +8,29 @@ public partial class SessionTerminalView : UserControl
{ {
public SessionTerminalView() { InitializeComponent(); } public SessionTerminalView() { InitializeComponent(); }
private DetailsIslandViewModel? _boundVm;
protected override void OnDataContextChanged(EventArgs e) protected override void OnDataContextChanged(EventArgs e)
{ {
base.OnDataContextChanged(e); base.OnDataContextChanged(e);
if (DataContext is DetailsIslandViewModel vm) if (_boundVm is not null)
vm.Log.CollectionChanged += OnLogChanged; _boundVm.Log.CollectionChanged -= OnLogChanged;
_boundVm = DataContext as DetailsIslandViewModel;
if (_boundVm is not null)
_boundVm.Log.CollectionChanged += OnLogChanged;
} }
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.Action != NotifyCollectionChangedAction.Add) return; if (e.Action != NotifyCollectionChangedAction.Add) return;
Dispatcher.UIThread.Post(() => LogScroll.ScrollToEnd(), DispatcherPriority.Background); // Scroll after the next layout pass so the freshly-added (wrapping) line
// is measured first — otherwise ScrollToEnd stops short and clips it.
EventHandler? handler = null;
handler = (_, _) =>
{
LogScroll.LayoutUpdated -= handler;
LogScroll.ScrollToEnd();
};
LogScroll.LayoutUpdated += handler;
} }
} }

View File

@@ -131,12 +131,30 @@
<!-- Status chip --> <!-- Status chip -->
<Border Classes="chip" <Border Classes="chip"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}" Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}" Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}" Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}"> Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
<TextBlock Text="{Binding Status}"/> <TextBlock Text="{Binding StatusLabel}"/>
</Border> </Border>
<!-- Review actions (visible when WaitingForReview) -->
<StackPanel Orientation="Horizontal" Spacing="4"
IsVisible="{Binding IsWaitingForReview}">
<Button Classes="btn" Content="Approve" MinWidth="0" Padding="8,2"
ToolTip.Tip="Approve — mark Done"
Click="OnApproveReviewClick"/>
<Button Classes="btn" Content="Reject" MinWidth="0" Padding="8,2"
ToolTip.Tip="Reject with feedback and re-run"
Click="OnRejectReviewClick"/>
<Button Classes="btn" Content="Park" MinWidth="0" Padding="8,2"
ToolTip.Tip="Send back to Idle for manual editing"
Click="OnParkReviewClick"/>
<Button Classes="btn" Content="Cancel" MinWidth="0" Padding="8,2"
ToolTip.Tip="Cancel this task"
Click="OnCancelReviewClick"/>
</StackPanel>
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) --> <!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
<Button Classes="icon-btn dequeue-btn" <Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding CanRemoveFromQueue}" IsVisible="{Binding CanRemoveFromQueue}"
@@ -175,20 +193,6 @@
</Border> </Border>
</StackPanel> </StackPanel>
<!-- Live-tail row (visible when running + has tail) -->
<Border Classes="task-live-tail" IsVisible="{Binding HasLiveTail}">
<StackPanel Spacing="3">
<TextBlock Text="{Binding LiveTail}"
TextTrimming="CharacterEllipsis" MaxLines="1"/>
<Grid Height="3" HorizontalAlignment="Stretch">
<Rectangle Fill="{DynamicResource Surface3Brush}"
HorizontalAlignment="Stretch" RadiusX="1.5" RadiusY="1.5"/>
<Rectangle Fill="{DynamicResource MossBrush}"
HorizontalAlignment="Left" Width="60" RadiusX="1.5" RadiusY="1.5"/>
</Grid>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
<!-- Star toggle --> <!-- Star toggle -->
@@ -241,5 +245,36 @@
</Flyout> </Flyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
<!-- Hidden reject-feedback anchor (its Flyout is shown from the Reject button) -->
<Button Grid.Row="1" x:Name="RejectAnchor"
Width="1" Height="1" Opacity="0"
HorizontalAlignment="Left" VerticalAlignment="Top"
IsHitTestVisible="False" Focusable="False">
<Button.Flyout>
<Flyout Placement="Bottom" ShowMode="Standard">
<Border Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="10"
Padding="16" Width="320">
<StackPanel Spacing="12">
<TextBlock Classes="title" Text="Reject &amp; re-run"/>
<StackPanel Spacing="6">
<TextBlock Classes="eyebrow" Text="FEEDBACK FOR THE AGENT"
Foreground="{DynamicResource TextDimBrush}" Opacity="0.6"/>
<TextBox x:Name="RejectFeedback"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="80" PlaceholderText="What should the agent fix?"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,4,0,0">
<Button Classes="btn" Content="Cancel" Click="OnRejectCancelClick" MinWidth="76"/>
<Button Content="Re-run" Classes="accent" Click="OnRejectConfirmClick" MinWidth="76"/>
</StackPanel>
</StackPanel>
</Border>
</Flyout>
</Button.Flyout>
</Button>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -88,6 +88,43 @@ public partial class TaskRowView : UserControl
await vm.SetStatusOnRowAsync(row, status); await vm.SetStatusOnRowAsync(row, status);
} }
private async void OnApproveReviewClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ApproveReviewCommand.ExecuteAsync(row);
}
private async void OnParkReviewClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RejectReviewToIdleCommand.ExecuteAsync(row);
}
private async void OnCancelReviewClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.CancelReviewCommand.ExecuteAsync(row);
}
private void OnRejectReviewClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel) return;
RejectFeedback.Text = "";
RejectAnchor.Flyout?.ShowAt(RejectAnchor);
}
private async void OnRejectConfirmClick(object? sender, RoutedEventArgs e)
{
RejectAnchor.Flyout?.Hide();
if (DataContext is not TaskRowViewModel row || FindTasksVm() is not { } vm) return;
var feedback = RejectFeedback.Text ?? "";
if (string.IsNullOrWhiteSpace(feedback)) return;
await vm.RejectReviewToQueueAsync(row, feedback);
}
private void OnRejectCancelClick(object? sender, RoutedEventArgs e)
=> RejectAnchor.Flyout?.Hide();
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;

View File

@@ -126,8 +126,17 @@
<Binding Path="IsShowingCompleted"/> <Binding Path="IsShowingCompleted"/>
</MultiBinding> </MultiBinding>
</StackPanel.IsVisible> </StackPanel.IsVisible>
<TextBlock Classes="eyebrow section-label" <Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
Text="{Binding CompletedHeader}" Margin="14,14,14,6"/> <TextBlock Grid.Column="0" Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding ClearCompletedCommand}"
ToolTip.Tip="Clear all completed"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding CompletedItems}"> <ItemsControl ItemsSource="{Binding CompletedItems}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel"> <DataTemplate DataType="vm:TaskRowViewModel">

View File

@@ -82,7 +82,7 @@
<PathIcon Data="{StaticResource Icon.WinMin}" Width="10" Height="10"/> <PathIcon Data="{StaticResource Icon.WinMin}" Width="10" Height="10"/>
</Button> </Button>
<Button Classes="title-ctrl" Click="OnToggleMax"> <Button Classes="title-ctrl" Click="OnToggleMax">
<PathIcon Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/> <PathIcon x:Name="MaxIcon" Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
</Button> </Button>
<Button Classes="title-ctrl close" Click="OnClose"> <Button Classes="title-ctrl close" Click="OnClose">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="10" Height="10"/> <PathIcon Data="{StaticResource Icon.WinClose}" Width="10" Height="10"/>

View File

@@ -19,6 +19,21 @@ public partial class MainWindow : Window
InitializeComponent(); InitializeComponent();
KeyDown += OnWindowKeyDown; KeyDown += OnWindowKeyDown;
DataContextChanged += OnDataContextChanged; DataContextChanged += OnDataContextChanged;
UpdateMaxIcon();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
UpdateMaxIcon();
}
private void UpdateMaxIcon()
{
var key = WindowState == WindowState.Maximized ? "Icon.WinRestore" : "Icon.WinMax";
if (this.TryFindResource(key, out var geometry) && geometry is Geometry g)
MaxIcon.Data = g;
} }
private void OnDataContextChanged(object? sender, EventArgs e) private void OnDataContextChanged(object? sender, EventArgs e)

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView" x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView"
x:DataType="vm:DiffModalViewModel" x:DataType="vm:DiffModalViewModel"
Title="Diff" Title="Diff"
Width="1200" Height="800" Width="1200" Height="800" MinWidth="700" MinHeight="450"
WindowDecorations="None" CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">

View File

@@ -8,8 +8,9 @@
Width="520" Height="720" Width="520" Height="720"
CanResize="True" CanResize="True"
MinWidth="460" MinHeight="520" MinWidth="460" MinHeight="520"
WindowDecorations="None" WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,10 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView" x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
x:DataType="vm:MergeModalViewModel" x:DataType="vm:MergeModalViewModel"
Title="Merge worktree" Title="Merge worktree"
Width="560" Height="460" Width="560" Height="460" MinWidth="460" MinHeight="360"
CanResize="False" CanResize="True"
WindowDecorations="None" WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView" x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel" x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists" Title="Add repos as lists"
Width="560" Height="480" Width="560" Height="480" MinWidth="420" MinHeight="320"
WindowDecorations="None" CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings> <Window.KeyBindings>

View File

@@ -2,14 +2,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings" xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView" x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
x:DataType="vm:SettingsModalViewModel" x:DataType="vm:SettingsModalViewModel"
Title="Settings" Title="Settings"
Width="580" Height="760" Width="580" Height="760" MinWidth="480" MinHeight="520"
WindowDecorations="None" CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">
@@ -17,10 +18,6 @@
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Window.Resources>
<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>
<conv:TimeSpanToHhmmConverter x:Key="TimeSpanToHhmm"/>
</Window.Resources>
<ctl:ModalShell Title="SETTINGS" CloseCommand="{Binding CancelCommand}"> <ctl:ModalShell Title="SETTINGS" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
@@ -73,6 +70,14 @@
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Max parallel executions"/>
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
Minimum="1" Maximum="20" Increment="1" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="How many queued tasks the worker runs at once."
Opacity="0.6" FontSize="12"/>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
@@ -171,29 +176,31 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<TextBlock Classes="meta" TextWrapping="Wrap" <TextBlock Classes="meta" TextWrapping="Wrap"
Text="Prime your Claude usage window each morning by firing a single non-interactive ping at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/> Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
<ItemsControl ItemsSource="{Binding Prime.Rows}"> <ItemsControl ItemsSource="{Binding Prime.Rows}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel"> <DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10,8" Margin="0,0,0,8" CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
Background="{DynamicResource DeepBrush}"> Background="{DynamicResource DeepBrush}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto" ColumnSpacing="8"> <Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/> <CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<ctl:ThemedDatePicker Grid.Column="1" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
IsRange="True" <ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
StartDate="{Binding StartDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}" <ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
EndDate="{Binding EndDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}" <ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
Watermark="Pick a range" <ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TimePicker Grid.Column="2"
SelectedTime="{Binding TimeOfDay, Mode=TwoWay}"
ClockIdentifier="24HourClock" MinuteIncrement="5"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBox Grid.Column="2" Width="64" <TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<CheckBox Grid.Column="3" Content="MonFri"
IsChecked="{Binding WorkdaysOnly, Mode=TwoWay}" VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="4" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/> MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="5" Content="✕" <Button Classes="icon-btn" Grid.Column="4" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}" Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
</Grid> </Grid>

View File

@@ -28,7 +28,10 @@
<!-- Title strip --> <!-- Title strip -->
<Border DockPanel.Dock="Top" Height="36" <Border DockPanel.Dock="Top" Height="36"
PointerPressed="OnTitleBarPressed"> Background="Transparent"
PointerPressed="OnTitleBarPressed"
PointerMoved="OnTitleBarMoved"
PointerReleased="OnTitleBarReleased">
<Grid ColumnDefinitions="*,Auto" Margin="14,0"> <Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Grid.Column="0" Text="Worktree" VerticalAlignment="Center" <TextBlock Grid.Column="0" Text="Worktree" VerticalAlignment="Center"
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeBody}" FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeBody}"

View File

@@ -66,9 +66,31 @@ public partial class WorktreeModalView : Window
e.Handled = true; e.Handled = true;
} }
private PixelPoint _dragStartScreen;
private PixelPoint _dragStartPos;
private bool _dragging;
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{ {
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
BeginMoveDrag(e); _dragStartScreen = this.PointToScreen(e.GetPosition(this));
_dragStartPos = Position;
_dragging = true;
e.Pointer.Capture(sender as IInputElement);
}
private void OnTitleBarMoved(object? sender, PointerEventArgs e)
{
if (!_dragging || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var cur = this.PointToScreen(e.GetPosition(this));
Position = new PixelPoint(
_dragStartPos.X + (cur.X - _dragStartScreen.X),
_dragStartPos.Y + (cur.Y - _dragStartScreen.Y));
}
private void OnTitleBarReleased(object? sender, PointerReleasedEventArgs e)
{
_dragging = false;
e.Pointer.Capture(null);
} }
} }

View File

@@ -11,7 +11,8 @@
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}" Background="{DynamicResource SurfaceBrush}"
WindowDecorations="BorderOnly" WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"> ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1">
<Window.Resources> <Window.Resources>
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/> <converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>

View File

@@ -5,9 +5,11 @@
x:DataType="vm:ConflictResolutionViewModel" x:DataType="vm:ConflictResolutionViewModel"
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView" x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
Title="Merge conflict" Title="Merge conflict"
Width="560" SizeToContent="Height" Width="560" SizeToContent="Height" MinWidth="460"
WindowDecorations="None" CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">

View File

@@ -5,9 +5,11 @@
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView" x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
x:DataType="vm:PlanningDiffViewModel" x:DataType="vm:PlanningDiffViewModel"
Title="Planning — Combined diff" Title="Planning — Combined diff"
Width="1100" Height="700" Width="1100" Height="700" MinWidth="700" MinHeight="450"
WindowDecorations="None" CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}"> Background="{DynamicResource SurfaceBrush}">

View File

@@ -27,7 +27,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern: - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask` - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `CancelTask`, `DeleteTask`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList` - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig` - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) - `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
@@ -41,21 +41,25 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
| Field | Values | Meaning | | Field | Values | Meaning |
|---|---|---| |---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `Done`, `Failed`, `Cancelled` | Lifecycle only. | | `Status` | `Idle`, `Queued`, `Running`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | | `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. | | `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
Allowed transitions (enforced by `TaskStateService`): Allowed transitions (enforced by `TaskStateService`):
``` ```
Idle → Queued | Running (RunNow) Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle Queued → Running | Cancelled | Idle
Running → Done | Failed | Cancelled Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
Done → Idle (re-run) Done → Idle (re-run)
Failed → Idle | Queued Failed → Idle | Queued
Cancelled → Idle | Queued Cancelled → Idle | Queued
``` ```
Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview` on success. Planning children go straight to `Done` so the sequential chain (which advances on terminal states) is unaffected. `TaskRunner.HandleSuccess` makes this choice; review transitions live in `TaskStateService` (`SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
## Planning Flow ## Planning Flow
`PlanningSessionManager.FinalizeAsync` is the single path: `PlanningSessionManager.FinalizeAsync` is the single path:
@@ -100,7 +104,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
## SignalR Hub ## SignalR Hub
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings` **WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated` **HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`

View File

@@ -1,15 +1,24 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto( public sealed record TaskDto(
string Id, string Id,
@@ -23,6 +32,23 @@ public sealed record TaskDto(
DateTime? StartedAt, DateTime? StartedAt,
DateTime? FinishedAt); DateTime? FinishedAt);
public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty);
public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted);
[McpServerToolType] [McpServerToolType]
public sealed class ExternalMcpService public sealed class ExternalMcpService
{ {
@@ -31,19 +57,31 @@ public sealed class ExternalMcpService
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
public ExternalMcpService( public ExternalMcpService(
TaskRepository tasks, TaskRepository tasks,
ListRepository lists, ListRepository lists,
QueueService queue, QueueService queue,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
ITaskStateService state) ITaskStateService state,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge)
{ {
_tasks = tasks; _tasks = tasks;
_lists = lists; _lists = lists;
_queue = queue; _queue = queue;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_state = state; _state = state;
_git = git;
_dbFactory = dbFactory;
_maintenance = maintenance;
_merge = merge;
} }
[McpServerTool, Description("List all task lists available in ClaudeDo.")] [McpServerTool, Description("List all task lists available in ClaudeDo.")]
@@ -53,7 +91,9 @@ public sealed class ExternalMcpService
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); 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.")] [McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")]
public async Task<IReadOnlyList<TaskDto>> ListTasks( public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId, string listId,
string? createdBy, string? createdBy,
@@ -64,7 +104,8 @@ public sealed class ExternalMcpService
if (!string.IsNullOrWhiteSpace(status)) if (!string.IsNullOrWhiteSpace(status))
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'."); throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
statusFilter = parsed; statusFilter = parsed;
} }
@@ -78,7 +119,11 @@ public sealed class ExternalMcpService
return query.Select(ToDto).ToList(); return query.Select(ToDto).ToList();
} }
[McpServerTool, Description("Get a single task by id, including its current status and result.")] [McpServerTool, Description(
"Get a single task by id, including its current status and result. " +
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken) public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -90,17 +135,15 @@ public sealed class ExternalMcpService
public async Task<TaskDto> AddTask( public async Task<TaskDto> AddTask(
string listId, string listId,
string title, string title,
string? description, string? description = null,
string createdBy, string? createdBy = null,
bool queueImmediately, bool queueImmediately = false,
CancellationToken cancellationToken) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(listId)) if (string.IsNullOrWhiteSpace(listId))
throw new InvalidOperationException("listId is required."); throw new InvalidOperationException("listId is required.");
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required."); throw new InvalidOperationException("title is required.");
if (string.IsNullOrWhiteSpace(createdBy))
throw new InvalidOperationException("createdBy is required.");
var list = await _lists.GetByIdAsync(listId, cancellationToken) var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found."); ?? throw new InvalidOperationException($"List {listId} not found.");
@@ -114,13 +157,12 @@ public sealed class ExternalMcpService
Status = TaskStatus.Idle, Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType, CommitType = list.DefaultCommitType,
CreatedBy = createdBy, CreatedBy = createdBy.NullIfBlank() ?? "mcp",
}; };
await _tasks.AddAsync(entity, cancellationToken); await _tasks.AddAsync(entity, cancellationToken);
if (queueImmediately) if (queueImmediately)
{ {
// Routes through TaskStateService so the queue is woken automatically.
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
if (!enqueue.Ok) if (!enqueue.Ok)
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
@@ -154,14 +196,19 @@ public sealed class ExternalMcpService
return ToDto(reload); return ToDto(reload);
} }
[McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] [McpServerTool, Description(
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
public async Task<TaskDto> UpdateTaskStatus( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,
string status, string status,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
throw new InvalidOperationException($"Unknown status '{status}'."); throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -181,13 +228,45 @@ public sealed class ExternalMcpService
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask."); $"Status '{target}' is not settable externally. Use run_task_now or cancel_task.");
} }
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
return ToDto(reload); return ToDto(reload);
} }
[McpServerTool, Description(
"Review a task that is WaitingForReview. " +
"decision='approve' → Done. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
public async Task<TaskDto> ReviewTask(
string taskId,
string decision,
string? feedback,
CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult result = decision.Trim().ToLowerInvariant() switch
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
{ {
@@ -206,16 +285,16 @@ public sealed class ExternalMcpService
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
} }
[McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")] [McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
public async Task<bool> CancelTask(string taskId, CancellationToken cancellationToken) public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
{ {
var cancelled = _queue.CancelTask(taskId); var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId); if (cancelled) await _broadcaster.TaskUpdated(taskId);
return cancelled; return new CancelTaskResult(cancelled, taskId);
} }
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")] [McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
public async Task DeleteTask(string taskId, CancellationToken cancellationToken) public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -224,6 +303,258 @@ public sealed class ExternalMcpService
await _tasks.DeleteAsync(taskId, cancellationToken); await _tasks.DeleteAsync(taskId, cancellationToken);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId);
}
// ── Status reference ─────────────────────────────────────────────────────
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
Task.FromResult<IReadOnlyList<StatusValueDto>>([
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
]);
// ── Worktree / git tools ──────────────────────────────────────────────────
[McpServerTool, Description(
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
"isDirty (has uncommitted changes in the worktree directory). " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
? wt.HeadCommit
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty);
}
[McpServerTool, Description(
"Get the diff for a task's worktree relative to its base commit. " +
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
"files always lists the changed file paths regardless of stat mode. " +
"totalBytes is the uncapped diff size (useful when truncated=true). " +
"Throws if the task has no worktree or the worktree directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId, bool stat = false, CancellationToken cancellationToken = default)
{
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
if (!Directory.Exists(wt.Path))
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
const int maxBytes = 200 * 1024;
if (stat)
{
var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
return new TaskDiffDto(diff, files, false, diff.Length);
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
}
[McpServerTool, Description(
"Merge a task's worktree branch into targetBranch (default: main). " +
"noFf=true (default): always creates a merge commit (--no-ff). " +
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
bool noFf = true,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Done)
throw new InvalidOperationException(
$"Task must be Done to merge (current status: {task.Status}). " +
"Valid statuses for merge: Done.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
if (dryRun)
{
using var ctx = _dbFactory.CreateDbContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (wt.State != WorktreeState.Active)
throw new InvalidOperationException(
$"Worktree state must be Active to merge (current: {wt.State}).");
return new MergeTaskResultDto(false, null, []);
}
var commitMessage = $"Merge task branch for: {task.Title}";
var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);
if (result.Status == TaskMergeService.StatusMerged)
{
string? mergeCommit = null;
try
{
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
}
catch { /* mergeCommit is optional */ }
return new MergeTaskResultDto(true, mergeCommit, []);
}
if (result.Status == TaskMergeService.StatusConflict)
return new MergeTaskResultDto(false, null, result.ConflictFiles);
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
}
[McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
"Only worktrees recorded in the ClaudeDo database are returned.")]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
var results = await Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
}));
return results;
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. " +
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId, bool force = false, CancellationToken cancellationToken = default)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot remove worktree of a running task.");
if (!force && Directory.Exists(wt.Path))
{
var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
if (isDirty)
throw new InvalidOperationException(
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
}
var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
}
// ── Private helpers ───────────────────────────────────────────────────────
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
return (task, list, wt);
}
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
{
try { return await _git.HasChangesAsync(path, ct); }
catch { return false; }
}
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
{
try
{
var psi = new ProcessStartInfo("git")
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(dir);
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync(CancellationToken.None);
ct.ThrowIfCancellationRequested();
return proc.ExitCode == 0 ? stdout.Trim() : null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
{
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
return int.TryParse(result, out var n) ? n : 0;
}
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
{
var files = new List<string>();
foreach (var line in diff.Split('\n'))
{
var s = line.TrimEnd('\r');
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
files.Add(s[6..]);
}
return files;
}
private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
{
var files = new List<string>();
foreach (var line in stat.Split('\n'))
{
var idx = line.IndexOf('|');
if (idx > 0) files.Add(line[..idx].Trim());
}
return files;
} }
private static TaskDto ToDto(TaskEntity t) => new( private static TaskDto ToDto(TaskEntity t) => new(

View File

@@ -11,6 +11,12 @@ public sealed record RunDto(
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut, int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt); DateTime? StartedAt, DateTime? FinishedAt);
public sealed record TaskLogResult(
bool Available,
IReadOnlyList<string> Entries,
int TotalLines,
bool Truncated);
[McpServerToolType] [McpServerToolType]
public sealed class RunHistoryMcpTools public sealed class RunHistoryMcpTools
{ {
@@ -33,26 +39,68 @@ public sealed class RunHistoryMcpTools
return ToDto(run); return ToDto(run);
} }
private const int MaxLogBytes = 256 * 1024; [McpServerTool, Description(
"Fetch log entries from a task's latest run. " +
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")] "Returns { available, entries, totalLines, truncated }. " +
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken) "available=false means no log exists yet (task is queued or just started — not an error). " +
"entries are the individual lines (NDJSON messages) from Claude's streaming output. " +
"Default: returns the last 50 entries (tail=50). " +
"tail: override the number of trailing entries to return. " +
"offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " +
"truncated=true when fewer entries are returned than totalLines.")]
public async Task<TaskLogResult> GetTaskLog(
string taskId,
int? tail = null,
int? offset = null,
int? limit = null,
CancellationToken cancellationToken = default)
{ {
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken) var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
?? throw new InvalidOperationException($"No runs found for task {taskId}."); if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath)) return new TaskLogResult(false, [], 0, false);
throw new InvalidOperationException("No log available for the latest run.");
var totalBytes = new FileInfo(run.LogPath).Length; string allText;
if (totalBytes <= MaxLogBytes) try
return await File.ReadAllTextAsync(run.LogPath, cancellationToken); {
await using var fs = new FileStream(
run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fs);
allText = await reader.ReadToEndAsync(cancellationToken);
}
catch (IOException)
{
return new TaskLogResult(false, [], 0, false);
}
var buffer = new byte[MaxLogBytes]; var lines = allText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var totalLines = lines.Length;
fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin);
var read = await fs.ReadAsync(buffer, cancellationToken); IReadOnlyList<string> entries;
var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read); bool truncated;
return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}";
if (offset.HasValue || limit.HasValue)
{
var start = Math.Max(0, offset ?? 0);
var count = limit.HasValue ? Math.Min(limit.Value, totalLines - start) : totalLines - start;
entries = lines.Skip(start).Take(count).ToArray();
truncated = start > 0 || (start + count) < totalLines;
}
else
{
var take = tail ?? 50;
if (totalLines <= take)
{
entries = lines;
truncated = false;
}
else
{
entries = lines[^take..];
truncated = true;
}
}
return new TaskLogResult(true, entries, totalLines, truncated);
} }
private static RunDto ToDto(TaskRunEntity r) => new( private static RunDto ToDto(TaskRunEntity r) => new(

View File

@@ -22,6 +22,7 @@ public record AppSettingsDto(
string DefaultModel, string DefaultModel,
int DefaultMaxTurns, int DefaultMaxTurns,
string DefaultPermissionMode, string DefaultPermissionMode,
int MaxParallelExecutions,
string WorktreeStrategy, string WorktreeStrategy,
string? CentralWorktreeRoot, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, bool WorktreeAutoCleanupEnabled,
@@ -202,6 +203,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.DefaultModel, row.DefaultModel,
row.DefaultMaxTurns, row.DefaultMaxTurns,
row.DefaultPermissionMode, row.DefaultPermissionMode,
row.MaxParallelExecutions,
row.WorktreeStrategy, row.WorktreeStrategy,
row.CentralWorktreeRoot, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupEnabled,
@@ -219,6 +221,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias, DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
DefaultMaxTurns = dto.DefaultMaxTurns, DefaultMaxTurns = dto.DefaultMaxTurns,
DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode, DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
MaxParallelExecutions = dto.MaxParallelExecutions,
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling", WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
CentralWorktreeRoot = dto.CentralWorktreeRoot, CentralWorktreeRoot = dto.CentralWorktreeRoot,
WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled, WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled,
@@ -358,6 +361,30 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed"); if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
} }
public async Task ApproveReview(string taskId)
{
var result = await _state.ApproveReviewAsync(taskId, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "approve failed");
}
public async Task RejectReviewToQueue(string taskId, string feedback)
{
var result = await _state.RejectToQueueAsync(taskId, feedback, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "reject failed");
}
public async Task RejectReviewToIdle(string taskId)
{
var result = await _state.RejectToIdleAsync(taskId, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "park failed");
}
public async Task CancelReview(string taskId)
{
var result = await _state.CancelAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "cancel failed");
}
public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto) public async Task UpdateTaskAgentSettings(UpdateTaskAgentSettingsDto dto)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
@@ -463,8 +490,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync(); var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto( return rows.Select(e => new PrimeScheduleDto(
e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
} }
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto) public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
@@ -475,10 +501,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{ {
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id, Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
StartDate = dto.StartDate, Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
EndDate = dto.EndDate,
TimeOfDay = dto.TimeOfDay, TimeOfDay = dto.TimeOfDay,
WorkdaysOnly = dto.WorkdaysOnly,
Enabled = dto.Enabled, Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride, PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow, CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
@@ -486,8 +510,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
}; };
await repo.UpsertAsync(entity); await repo.UpsertAsync(entity);
_primeSignal.Signal(); _primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, entity.StartDate, entity.EndDate, entity.TimeOfDay, return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride); entity.Enabled, entity.LastRunAt, entity.PromptOverride);
} }
public async Task DeletePrimeSchedule(Guid id) public async Task DeletePrimeSchedule(Guid id)

View File

@@ -1,3 +1,5 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime; namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately); public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
@@ -22,16 +24,13 @@ public static class NextDueCalculator
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp) private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
{ {
if (s.EndDate < DateOnly.FromDateTime(now.LocalDateTime)) return null; if ((PrimeDays)s.Days == PrimeDays.None) return null;
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime); var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last && var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal; DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
if (!alreadyFiredToday) if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
{
var startOrToday = s.StartDate > todayLocal ? s.StartDate : todayLocal;
if (startOrToday == todayLocal && IsEligibleDay(s, todayLocal))
{ {
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset); var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (todayTarget >= now) if (todayTarget >= now)
@@ -39,13 +38,10 @@ public static class NextDueCalculator
if (now <= todayTarget + catchUp) if (now <= todayTarget + catchUp)
return new NextDue(s, now, true); return new NextDue(s, now, true);
} }
}
var d = todayLocal.AddDays(1); var d = todayLocal.AddDays(1);
if (s.StartDate > d) d = s.StartDate; for (int i = 0; i < 7; i++)
for (int i = 0; i < 8; i++)
{ {
if (d > s.EndDate) return null;
if (IsEligibleDay(s, d)) if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false); return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1); d = d.AddDays(1);
@@ -53,13 +49,20 @@ public static class NextDueCalculator
return null; return null;
} }
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
{ {
if (d < s.StartDate || d > s.EndDate) return false; DayOfWeek.Monday => PrimeDays.Monday,
if (!s.WorkdaysOnly) return true; DayOfWeek.Tuesday => PrimeDays.Tuesday,
var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek; DayOfWeek.Wednesday => PrimeDays.Wednesday,
return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday; DayOfWeek.Thursday => PrimeDays.Thursday,
} DayOfWeek.Friday => PrimeDays.Friday,
DayOfWeek.Saturday => PrimeDays.Saturday,
DayOfWeek.Sunday => PrimeDays.Sunday,
_ => PrimeDays.None,
};
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) => private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset); new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);

View File

@@ -2,10 +2,8 @@ namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto( public sealed record PrimeScheduleDto(
Guid Id, Guid Id,
DateOnly StartDate, int Days,
DateOnly EndDate,
TimeSpan TimeOfDay, TimeSpan TimeOfDay,
bool WorkdaysOnly,
bool Enabled, bool Enabled,
DateTimeOffset? LastRunAt, DateTimeOffset? LastRunAt,
string? PromptOverride); string? PromptOverride);

View File

@@ -102,7 +102,7 @@ public sealed class PrimeScheduler : BackgroundService
} }
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) => private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, e.StartDate, e.EndDate, e.TimeOfDay, e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride); new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct) private async Task FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{ {

View File

@@ -204,6 +204,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
externalBuilder.Services.AddScoped<ExternalMcpService>(); externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddScoped<ListMcpTools>(); externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>(); externalBuilder.Services.AddScoped<ConfigMcpTools>();

View File

@@ -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.Runner; using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Queue; namespace ClaudeDo.Worker.Queue;
@@ -16,9 +18,10 @@ public sealed class QueueService : BackgroundService
private readonly QueueWaker _waker; private readonly QueueWaker _waker;
private readonly IQueuePicker _picker; private readonly IQueuePicker _picker;
private readonly OverrideSlotService _override; private readonly OverrideSlotService _override;
private readonly ITaskStateService _state;
private readonly object _lock = new(); private readonly object _lock = new();
private volatile QueueSlotState? _queueSlot; private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
public QueueService( public QueueService(
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -27,7 +30,8 @@ public sealed class QueueService : BackgroundService
ILogger<QueueService> logger, ILogger<QueueService> logger,
QueueWaker waker, QueueWaker waker,
IQueuePicker picker, IQueuePicker picker,
OverrideSlotService overrideSlot) OverrideSlotService overrideSlot,
ITaskStateService state)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_runner = runner; _runner = runner;
@@ -36,13 +40,17 @@ public sealed class QueueService : BackgroundService
_waker = waker; _waker = waker;
_picker = picker; _picker = picker;
_override = overrideSlot; _override = overrideSlot;
_state = state;
} }
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive() public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
{ {
var list = new List<(string, string, DateTime)>(); var list = new List<(string, string, DateTime)>();
var q = _queueSlot; lock (_lock)
if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt)); {
foreach (var slot in _queueSlots.Values)
list.Add(("queue", slot.TaskId, slot.StartedAt));
}
var o = _override.CurrentSlot; var o = _override.CurrentSlot;
if (o is not null) list.Add(("override", o.TaskId, o.StartedAt)); if (o is not null) list.Add(("override", o.TaskId, o.StartedAt));
return list; return list;
@@ -64,7 +72,7 @@ public sealed class QueueService : BackgroundService
{ {
lock (_lock) lock (_lock)
{ {
if (_queueSlot?.TaskId == taskId) if (_queueSlots.ContainsKey(taskId))
throw new InvalidOperationException("task is already running in queue slot"); throw new InvalidOperationException("task is already running in queue slot");
} }
} }
@@ -75,9 +83,9 @@ public sealed class QueueService : BackgroundService
lock (_lock) lock (_lock)
{ {
if (_queueSlot is not null && _queueSlot.TaskId == taskId) if (_queueSlots.TryGetValue(taskId, out var slot))
{ {
_queueSlot.Cts.Cancel(); slot.Cts.Cancel();
return true; return true;
} }
} }
@@ -100,28 +108,35 @@ public sealed class QueueService : BackgroundService
await Task.WhenAny(wakeTask, timerTask); await Task.WhenAny(wakeTask, timerTask);
if (_queueSlot is not null) continue; var maxParallel = await GetMaxParallelAsync(stoppingToken);
// Fill as many free slots as the limit allows.
while (!stoppingToken.IsCancellationRequested)
{
lock (_lock)
{
if (_queueSlots.Count >= maxParallel) break;
}
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken); var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
if (task is null) continue; if (task is null) break;
lock (_lock) lock (_lock)
{ {
if (_queueSlot is not null) continue;
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts }; _queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t => _ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
{ {
if (t.IsFaulted) if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id); _logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
lock (_lock) { _queueSlot = null; } lock (_lock) { _queueSlots.Remove(task.Id); }
cts.Dispose(); cts.Dispose();
_waker.Wake(); // Check for next task immediately. _waker.Wake(); // Check for next task immediately.
}, TaskScheduler.Default); }, TaskScheduler.Default);
} }
} }
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
break; break;
@@ -135,6 +150,21 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping"); _logger.LogInformation("QueueService stopping");
} }
private async Task<int> GetMaxParallelAsync(CancellationToken ct)
{
try
{
using var context = _dbFactory.CreateDbContext();
var settings = await new AppSettingsRepository(context).GetAsync(ct);
return Math.Max(1, settings.MaxParallelExecutions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read max parallel executions; defaulting to 1");
return 1;
}
}
private async Task RunInSlotAsync(string taskId, CancellationToken ct) private async Task RunInSlotAsync(string taskId, CancellationToken ct)
{ {
try try
@@ -149,6 +179,39 @@ public sealed class QueueService : BackgroundService
?? throw new KeyNotFoundException($"Task '{taskId}' not found."); ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
} }
// A task re-queued from review carries reviewer feedback. Resume the prior
// Claude session with that feedback as the next turn when a session exists;
// otherwise fall back to a fresh run with the feedback folded into the prompt.
if (!string.IsNullOrWhiteSpace(task.ReviewFeedback))
{
var feedback = task.ReviewFeedback!;
string? sessionId;
using (var context = _dbFactory.CreateDbContext())
sessionId = (await new TaskRunRepository(context).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
if (sessionId is not null)
{
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
}
else
{
task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer feedback: {feedback}"
: $"{task.Description}\n\nReviewer feedback: {feedback}";
await _runner.RunAsync(task, "queue", ct);
}
// Clear the consumed feedback only once the run reached a successful
// terminal state, so a failed or cancelled run keeps it for a manual retry.
TaskStatus statusAfter;
using (var context = _dbFactory.CreateDbContext())
statusAfter = await context.Tasks.Where(t => t.Id == taskId)
.Select(t => t.Status).FirstAsync(CancellationToken.None);
if (statusAfter is TaskStatus.WaitingForReview or TaskStatus.Done)
await _state.ClearReviewFeedbackAsync(taskId, CancellationToken.None);
return;
}
await _runner.RunAsync(task, "queue", ct); await _runner.RunAsync(task, "queue", ct);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -44,6 +44,14 @@ public sealed class StreamAnalyzer
_structuredOutputJson = structuredProp.ToString(); _structuredOutputJson = structuredProp.ToString();
if (root.TryGetProperty("session_id", out var sessionProp)) if (root.TryGetProperty("session_id", out var sessionProp))
_sessionId = sessionProp.GetString(); _sessionId = sessionProp.GetString();
// Authoritative token totals live on the result event.
if (root.TryGetProperty("usage", out var resultUsage))
{
if (resultUsage.TryGetProperty("input_tokens", out var inp))
_tokensIn = inp.GetInt32();
if (resultUsage.TryGetProperty("output_tokens", out var outp))
_tokensOut = outp.GetInt32();
}
break; break;
case "assistant": case "assistant":
@@ -66,7 +74,7 @@ public sealed class StreamAnalyzer
public StreamResult GetResult() => new() public StreamResult GetResult() => new()
{ {
ResultMarkdown = _resultMarkdown, ResultMarkdown = FallbackResult(),
StructuredOutputJson = _structuredOutputJson, StructuredOutputJson = _structuredOutputJson,
SessionId = _sessionId, SessionId = _sessionId,
TurnCount = _turnCount, TurnCount = _turnCount,
@@ -75,6 +83,20 @@ public sealed class StreamAnalyzer
ApiRetryCount = _apiRetryCount, ApiRetryCount = _apiRetryCount,
}; };
private string? FallbackResult()
{
if (!string.IsNullOrEmpty(_resultMarkdown)) return _resultMarkdown;
if (_structuredOutputJson is null) return _resultMarkdown;
try
{
using var doc = JsonDocument.Parse(_structuredOutputJson);
if (doc.RootElement.TryGetProperty("summary", out var s))
return s.GetString();
}
catch { }
return _structuredOutputJson;
}
private void TryAccumulateUsage(JsonElement root) private void TryAccumulateUsage(JsonElement root)
{ {
if (!root.TryGetProperty("event", out var eventProp)) return; if (!root.TryGetProperty("event", out var eventProp)) return;

View File

@@ -238,6 +238,11 @@ public sealed class TaskRunner
{ {
var runRepo = new TaskRunRepository(context); var runRepo = new TaskRunRepository(context);
await runRepo.AddAsync(run, ct); await runRepo.AddAsync(run, ct);
// Point the task at this run's log immediately so the UI can replay
// live output when the user navigates away and back mid-run.
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
} }
await _broadcaster.RunCreated(taskId, runNumber, isRetry); await _broadcaster.RunCreated(taskId, runNumber, isRetry);
@@ -277,9 +282,6 @@ public sealed class TaskRunner
{ {
var runRepo = new TaskRunRepository(context); var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None); await runRepo.UpdateAsync(run, CancellationToken.None);
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
} }
return result; return result;
@@ -296,9 +298,6 @@ public sealed class TaskRunner
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var runRepo = new TaskRunRepository(context); var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None); await runRepo.UpdateAsync(run, CancellationToken.None);
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
} }
catch (Exception updateEx) catch (Exception updateEx)
{ {
@@ -323,10 +322,22 @@ public sealed class TaskRunner
// Terminal DB write uses CancellationToken.None so the task status // Terminal DB write uses CancellationToken.None so the task status
// is never left as 'running' because of a cancel that arrived // is never left as 'running' because of a cancel that arrived
// after the Claude run already succeeded. // after the Claude run already succeeded.
// Standalone tasks gate on review; planning children go straight to Done
// so the sequential chain (which advances on terminal states) is unaffected.
// Planning parents (PlanningPhase != None) are containers, not reviewable work.
var finishedAt = DateTime.UtcNow; var finishedAt = DateTime.UtcNow;
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
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);
} }

View File

@@ -5,10 +5,16 @@ public interface ITaskStateService
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct); Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct); Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct); Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct);
Task<TransitionResult> RejectToQueueAsync(string taskId, string feedback, CancellationToken ct);
Task<TransitionResult> RejectToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ClearReviewFeedbackAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct); Task<TransitionResult> ForceSetStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status, CancellationToken ct);
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct); Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);

View File

@@ -90,6 +90,90 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null); return new TransitionResult(true, null);
} }
public async Task<TransitionResult> SubmitForReviewAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
if (affected == 0)
return new TransitionResult(false, "Task not running; cannot submit for review.");
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
{
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Done), ct);
if (affected == 0)
return new TransitionResult(false, "Task is not waiting for review; cannot approve.");
}
await OnChildTerminalAsync(taskId, TaskStatus.Done);
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> RejectToQueueAsync(string taskId, string feedback, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(feedback))
return new TransitionResult(false, "Feedback is required to reject for re-run.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Queued)
.SetProperty(t => t.ReviewFeedback, feedback)
.SetProperty(t => t.StartedAt, (DateTime?)null)
.SetProperty(t => t.FinishedAt, (DateTime?)null), ct);
if (affected == 0)
return new TransitionResult(false, "Task is not waiting for review; cannot reject.");
_waker.Wake();
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> RejectToIdleAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Idle)
.SetProperty(t => t.ReviewFeedback, (string?)null), ct);
if (affected == 0)
return new TransitionResult(false, "Task is not waiting for review; cannot park.");
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
public async Task<TransitionResult> ClearReviewFeedbackAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ReviewFeedback, (string?)null), ct);
return affected == 0
? new TransitionResult(false, "Task not found.")
: new TransitionResult(true, null);
}
public async Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct) public async Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct)
{ {
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
@@ -116,7 +200,8 @@ public sealed class TaskStateService : ITaskStateService
{ {
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && .Where(t => t.Id == taskId &&
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued)) (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|| t.Status == TaskStatus.WaitingForReview))
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Cancelled) .SetProperty(t => t.Status, TaskStatus.Cancelled)
.SetProperty(t => t.FinishedAt, finishedAt), ct); .SetProperty(t => t.FinishedAt, finishedAt), ct);

View File

@@ -40,6 +40,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null); public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public virtual Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;

View File

@@ -19,13 +19,14 @@ public class PrimeClaudeTabViewModelTests
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
} }
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null);
[Fact] [Fact]
public async Task Load_Populates_Rows() public async Task Load_Populates_Rows()
{ {
var api = new FakeApi(); var api = new FakeApi();
api.Stored.Add(new PrimeScheduleDto( api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31),
new TimeSpan(7,0,0), true, true, null, null));
var vm = new PrimeClaudeTabViewModel(api); var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync(); await vm.LoadAsync();
Assert.Single(vm.Rows); Assert.Single(vm.Rows);
@@ -38,8 +39,21 @@ public class PrimeClaudeTabViewModelTests
vm.AddScheduleCommand.Execute(null); vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows); Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled); Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].WorkdaysOnly); Assert.True(vm.Rows[0].Monday);
Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay); Assert.True(vm.Rows[0].Friday);
Assert.False(vm.Rows[0].Saturday);
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
}
[Fact]
public void Row_Decomposes_And_Recomposes_Days()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
Assert.Equal(31, row.DaysMask());
row.Saturday = true;
Assert.Equal(63, row.DaysMask());
} }
[Fact] [Fact]
@@ -48,8 +62,8 @@ public class PrimeClaudeTabViewModelTests
var api = new FakeApi(); var api = new FakeApi();
var keptId = Guid.NewGuid(); var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid(); var deletedId = Guid.NewGuid();
api.Stored.Add(new PrimeScheduleDto(keptId, new(2026,5,1), new(2026,5,31), new(7,0,0), true, true, null, null)); api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
api.Stored.Add(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null)); api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api); var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync(); await vm.LoadAsync();
@@ -63,12 +77,20 @@ public class PrimeClaudeTabViewModelTests
} }
[Fact] [Fact]
public void Validate_Reports_StartAfterEnd() public void Validate_Reports_No_Days_Selected()
{ {
var vm = new PrimeClaudeTabViewModel(new FakeApi()); var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null); vm.AddScheduleCommand.Execute(null);
vm.Rows[0].StartDate = new DateOnly(2026, 6, 1); var row = vm.Rows[0];
vm.Rows[0].EndDate = new DateOnly(2026, 5, 1); row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
Assert.NotNull(vm.Validate()); Assert.NotNull(vm.Validate());
} }
[Fact]
public void Validate_Passes_With_One_Day()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate());
}
} }

View File

@@ -5,10 +5,12 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External; using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -87,9 +89,17 @@ public sealed class ExternalMcpServiceTests : IDisposable
return task; return task;
} }
private ExternalMcpService BuildSut(QueueService queue) => private ExternalMcpService BuildSut(QueueService queue)
new(_tasks, _lists, queue, _broadcaster, {
TaskStateServiceBuilder.Build(_db.CreateFactory()).State); var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger<TaskMergeService>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
}
private QueueService CreateQueue() private QueueService CreateQueue()
{ {
@@ -107,12 +117,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
var dbFactory = _db.CreateFactory(); var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State); NullLogger<TaskRunner>.Instance, state);
var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot); return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
} }
[Fact] [Fact]
@@ -160,6 +171,54 @@ public sealed class ExternalMcpServiceTests : IDisposable
sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None)); sut.UpdateTask("does-not-exist", "x", null, null, CancellationToken.None));
} }
[Fact]
public async Task ReviewTask_Approve_SetsDone()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "approve", null, CancellationToken.None);
Assert.Equal("Done", dto.Status);
}
[Fact]
public async Task ReviewTask_RejectRerun_WithoutFeedback_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "reject_rerun", null, CancellationToken.None));
}
[Fact]
public async Task ReviewTask_RejectRerun_QueuesAndStoresFeedback()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var dto = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", CancellationToken.None);
Assert.Equal("Queued", dto.Status);
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal("fix it", loaded!.ReviewFeedback);
}
[Fact]
public async Task ReviewTask_UnknownDecision_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "bogus", null, CancellationToken.None));
}
[Fact] [Fact]
public async Task DeleteTask_RemovesTask() public async Task DeleteTask_RemovesTask()
{ {

View File

@@ -54,13 +54,16 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
} }
[Fact] [Fact]
public async Task GetTaskLog_NoLog_Throws() public async Task GetTaskLog_NoRun_ReturnsUnavailable()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
await Assert.ThrowsAsync<InvalidOperationException>(() => var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
_sut.GetTaskLog(taskId, CancellationToken.None));
Assert.False(result.Available);
Assert.Empty(result.Entries);
Assert.Equal(0, result.TotalLines);
} }
[Fact] [Fact]
@@ -69,24 +72,26 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(logPath, "hello log"); await File.WriteAllTextAsync(logPath, "line1\nline2\nline3");
await _runs.AddAsync(new TaskRunEntity await _runs.AddAsync(new TaskRunEntity
{ {
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath, IsRetry = false, Prompt = "p", LogPath = logPath,
}); });
string content; TaskLogResult result;
try try
{ {
content = await _sut.GetTaskLog(taskId, CancellationToken.None); result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
} }
finally finally
{ {
File.Delete(logPath); File.Delete(logPath);
} }
Assert.Equal("hello log", content); Assert.True(result.Available);
Assert.Equal(3, result.TotalLines);
Assert.Contains("line1", result.Entries);
} }
[Fact] [Fact]
@@ -97,7 +102,7 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
} }
[Fact] [Fact]
public async Task GetTaskLog_RunExistsButNoLogPath_Throws() public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
@@ -107,22 +112,22 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
IsRetry = false, Prompt = "p", LogPath = null, IsRetry = false, Prompt = "p", LogPath = null,
}); });
await Assert.ThrowsAsync<InvalidOperationException>(() => var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
_sut.GetTaskLog(taskId, CancellationToken.None));
Assert.False(result.Available);
Assert.Empty(result.Entries);
} }
[Fact] [Fact]
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail() public async Task GetTaskLog_ManyLines_DefaultTailReturnsLast50()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
// Write 300 KB so it exceeds the 256 KB cap // Write 108 lines (the observed real-world size that exceeded token limits)
var chunk = new string('A', 1024); var lines = Enumerable.Range(1, 108).Select(i => $"{{\"line\":{i}}}");
await using (var w = new StreamWriter(logPath, append: false)) await File.WriteAllLinesAsync(logPath, lines);
for (var i = 0; i < 300; i++)
await w.WriteAsync(chunk);
await _runs.AddAsync(new TaskRunEntity await _runs.AddAsync(new TaskRunEntity
{ {
@@ -130,17 +135,84 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
IsRetry = false, Prompt = "p", LogPath = logPath, IsRetry = false, Prompt = "p", LogPath = logPath,
}); });
string content; TaskLogResult result;
try try
{ {
content = await _sut.GetTaskLog(taskId, CancellationToken.None); result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
} }
finally finally
{ {
File.Delete(logPath); File.Delete(logPath);
} }
Assert.StartsWith("[truncated:", content); Assert.True(result.Available);
Assert.True(content.Length < 300 * 1024); Assert.True(result.Truncated);
Assert.Equal(108, result.TotalLines);
Assert.Equal(50, result.Entries.Count);
Assert.Contains("{\"line\":108}", result.Entries); // last line is present
Assert.DoesNotContain("{\"line\":1}", result.Entries); // first line is not
}
[Fact]
public async Task GetTaskLog_TailParam_ReturnsRequestedCount()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
var lines = Enumerable.Range(1, 20).Select(i => $"line{i}");
await File.WriteAllLinesAsync(logPath, lines);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
TaskLogResult result;
try
{
result = await _sut.GetTaskLog(taskId, tail: 5, cancellationToken: CancellationToken.None);
}
finally
{
File.Delete(logPath);
}
Assert.True(result.Available);
Assert.True(result.Truncated);
Assert.Equal(5, result.Entries.Count);
Assert.Equal("line20", result.Entries[^1]);
}
[Fact]
public async Task GetTaskLog_OffsetLimit_ReturnsSlice()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
var lines = Enumerable.Range(1, 10).Select(i => $"line{i}");
await File.WriteAllLinesAsync(logPath, lines);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
TaskLogResult result;
try
{
result = await _sut.GetTaskLog(taskId, offset: 2, limit: 3, cancellationToken: CancellationToken.None);
}
finally
{
File.Delete(logPath);
}
Assert.True(result.Available);
Assert.Equal(3, result.Entries.Count);
Assert.Equal("line3", result.Entries[0]);
Assert.Equal("line5", result.Entries[^1]);
Assert.True(result.Truncated);
} }
} }

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime; namespace ClaudeDo.Worker.Tests.Prime;
@@ -5,26 +6,34 @@ namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests public class NextDueCalculatorTests
{ {
private static PrimeScheduleDto Schedule( private static PrimeScheduleDto Schedule(
DateOnly start, DateOnly end, TimeSpan time, PrimeDays days, TimeSpan time,
bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) => bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null); new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
[Fact] [Fact]
public void Disabled_Schedule_Returns_Null() public void Disabled_Schedule_Returns_Null()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false); var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30))); Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void No_Days_Selected_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.None, new(7, 0, 0));
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
} }
[Fact] [Fact]
public void Future_Same_Day_Returns_Today_At_Target() public void Future_Same_Day_Returns_Today_At_Target()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(new DateTimeOffset(2026,5,5,7,0,0, now.Offset), r!.At); Assert.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
Assert.False(r.FireImmediately); Assert.False(r.FireImmediately);
} }
@@ -32,8 +41,8 @@ public class NextDueCalculatorTests
public void Within_CatchUp_Window_Fires_Immediately() public void Within_CatchUp_Window_Fires_Immediately()
{ {
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.True(r!.FireImmediately); Assert.True(r!.FireImmediately);
} }
@@ -41,50 +50,53 @@ public class NextDueCalculatorTests
[Fact] [Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day() public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{ {
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
} }
[Fact] [Fact]
public void WorkdaysOnly_Skips_Weekend() public void Weekdays_Only_Skips_Weekend()
{ {
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true); var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek); Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026,5,11), DateOnly.FromDateTime(r.At.LocalDateTime)); Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
} }
[Fact] [Fact]
public void Already_Fired_Today_Skips_To_Tomorrow() public void Single_Day_Schedule_Targets_That_Weekday()
{
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2)); var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), lastRun: lastRun); var s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(new DateOnly(2026,5,6), DateOnly.FromDateTime(r!.At.LocalDateTime)); Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Past_EndDate_Returns_Null()
{
var now = new DateTimeOffset(2026, 6, 1, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)));
} }
[Fact] [Fact]
public void Multiple_Schedules_Returns_Earliest() public void Multiple_Schedules_Returns_Earliest()
{ {
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var early = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0)); var early = Schedule(PrimeDays.All, new(7, 0, 0));
var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0)); var late = Schedule(PrimeDays.All, new(9, 0, 0));
var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30)); var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r); Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id); Assert.Equal(early.Id, r!.Schedule.Id);
} }

View File

@@ -44,10 +44,8 @@ public class PrimeSchedulerTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 5), Days = PrimeDays.All,
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0), TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}); });
@@ -86,10 +84,8 @@ public class PrimeSchedulerTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 5), Days = PrimeDays.All,
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0), TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}); });
@@ -128,10 +124,8 @@ public class PrimeSchedulerTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 5), Days = PrimeDays.All,
EndDate = new DateOnly(2026, 5, 5),
TimeOfDay = new TimeSpan(7, 0, 0), TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = false,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}); });

View File

@@ -19,10 +19,8 @@ public class PrimeScheduleRepositoryTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 1), Days = PrimeDays.Weekdays,
EndDate = new DateOnly(2026, 6, 30),
TimeOfDay = new TimeSpan(7, 0, 0), TimeOfDay = new TimeSpan(7, 0, 0),
WorkdaysOnly = true,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}); });
@@ -33,6 +31,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
Assert.Single(rows); Assert.Single(rows);
Assert.Equal(id, rows[0].Id); Assert.Equal(id, rows[0].Id);
Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay); Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay);
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
} }
[Fact] [Fact]
@@ -45,8 +44,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 1), Days = PrimeDays.Weekdays,
EndDate = new DateOnly(2026, 5, 31),
TimeOfDay = new TimeSpan(7, 0, 0), TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -69,8 +67,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{ {
Id = id, Id = id,
StartDate = new DateOnly(2026, 5, 1), Days = PrimeDays.All,
EndDate = new DateOnly(2026, 5, 1),
TimeOfDay = TimeSpan.Zero, TimeOfDay = TimeSpan.Zero,
Enabled = true, Enabled = true,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,

View File

@@ -79,4 +79,45 @@ public sealed class StreamAnalyzerTests
Assert.Null(result.ResultMarkdown); Assert.Null(result.ResultMarkdown);
Assert.Null(result.SessionId); Assert.Null(result.SessionId);
} }
[Fact]
public void Token_Usage_From_Result_Event()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1","usage":{"input_tokens":150,"output_tokens":75,"cache_read_input_tokens":0}}""");
var result = analyzer.GetResult();
Assert.Equal(150, result.TokensIn);
Assert.Equal(75, result.TokensOut);
}
[Fact]
public void Result_Usage_Overrides_Stream_Event_Accumulation()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":5}}}}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1","usage":{"input_tokens":200,"output_tokens":90}}""");
var result = analyzer.GetResult();
Assert.Equal(200, result.TokensIn);
Assert.Equal(90, result.TokensOut);
}
[Fact]
public void Empty_Result_Falls_Back_To_Structured_Output_Summary()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"","structured_output":{"summary":"Task completed successfully.","data":{}},"session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal("Task completed successfully.", result.ResultMarkdown);
Assert.Contains("summary", result.StructuredOutputJson);
}
[Fact]
public void Empty_Result_Falls_Back_To_Full_Json_When_No_Summary()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"","structured_output":{"output":"42"},"session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Contains("output", result.ResultMarkdown);
Assert.Contains("42", result.ResultMarkdown);
}
} }

View File

@@ -53,12 +53,13 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
var dbFactory = _db.CreateFactory(); var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance); var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State); NullLogger<TaskRunner>.Instance, state);
_waker = new QueueWaker(); _waker = new QueueWaker();
var picker = new QueuePicker(dbFactory); var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot); var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
return (service, fake); return (service, fake);
} }

View File

@@ -54,12 +54,13 @@ public sealed class QueueServiceTests : IDisposable
var dbFactory = _db.CreateFactory(); var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance); var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder(); var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State); NullLogger<TaskRunner>.Instance, state);
_waker = new QueueWaker(); _waker = new QueueWaker();
var picker = new QueuePicker(dbFactory); var picker = new QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot); var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, _waker, picker, overrideSlot, state);
return (service, fake); return (service, fake);
} }
@@ -112,6 +113,69 @@ public sealed class QueueServiceTests : IDisposable
await Assert.ThrowsAsync<KeyNotFoundException>(() => service.RunNow("nonexistent")); await Assert.ThrowsAsync<KeyNotFoundException>(() => service.RunNow("nonexistent"));
} }
[Fact]
public async Task ReQueuedReviewTask_ResumesSession_WithFeedbackPrompt_AndClearsFeedback()
{
var (listId, _) = await SeedListWithAgentTag();
string? capturedArgs = null;
string? capturedPrompt = null;
var done = new TaskCompletionSource();
var (service, _) = CreateService((prompt, _, args, _, _) =>
{
capturedPrompt = prompt;
capturedArgs = args;
done.TrySetResult();
return Task.FromResult(new RunResult { ExitCode = 0, SessionId = "sess-2", ResultMarkdown = "ok" });
});
// A task that was reviewed and rejected: Queued + ReviewFeedback, with a prior run carrying a session id.
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Reviewed task",
Status = TaskStatus.Queued,
ReviewFeedback = "fix the bug",
CreatedAt = DateTime.UtcNow,
};
await _taskRepo.AddAsync(task);
await new TaskRunRepository(_ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = task.Id,
RunNumber = 1,
IsRetry = false,
Prompt = "original",
SessionId = "sess-1",
StartedAt = DateTime.UtcNow.AddMinutes(-1),
});
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
_waker.Wake();
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Contains("--resume sess-1", capturedArgs);
Assert.Equal("fix the bug", capturedPrompt);
// Feedback is cleared after the run reaches a successful terminal state (post-run),
// so poll rather than asserting on the handler-fired instant.
var deadline = DateTime.UtcNow.AddSeconds(5);
TaskEntity? reloaded;
do
{
reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
if (reloaded?.ReviewFeedback is null) break;
await Task.Delay(25);
} while (DateTime.UtcNow < deadline);
cts.Cancel();
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
Assert.Null(reloaded.ReviewFeedback);
}
[Fact] [Fact]
public async Task Schedule_Filter_Skips_Future_Tasks() public async Task Schedule_Filter_Skips_Future_Tasks()
{ {
@@ -254,7 +318,8 @@ public sealed class QueueServiceTests : IDisposable
var finalTask = await _taskRepo.GetByIdAsync(task.Id); var finalTask = await _taskRepo.GetByIdAsync(task.Id);
Assert.NotNull(finalTask); Assert.NotNull(finalTask);
Assert.Equal(TaskStatus.Done, finalTask.Status); // A standalone task that completes successfully now gates on review.
Assert.Equal(TaskStatus.WaitingForReview, finalTask.Status);
} }
[Fact] [Fact]

View File

@@ -0,0 +1,206 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.State;
public sealed class ReviewTransitionTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TestDbContextFactory _factory;
private readonly TaskStateServiceBuilder.Built _built;
private readonly ITaskStateService _sut;
private readonly string _listId;
public ReviewTransitionTests()
{
_factory = _db.CreateFactory();
_built = TaskStateServiceBuilder.Build(_factory);
_sut = _built.State;
_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<string> SeedTaskAsync(TaskStatus status, string? result = null)
{
var id = Guid.NewGuid().ToString();
await using var ctx = _factory.CreateDbContext();
ctx.Tasks.Add(new TaskEntity
{
Id = id,
ListId = _listId,
Title = "task",
Status = status,
Result = result,
CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
return id;
}
private async Task<TaskEntity> GetTaskAsync(string id)
{
await using var ctx = _factory.CreateDbContext();
return await new TaskRepository(ctx).GetByIdAsync(id)
?? throw new InvalidOperationException($"task {id} not found");
}
// ─── SubmitForReviewAsync ─────────────────────────────────────────────
[Fact]
public async Task SubmitForReviewAsync_FromRunning_TransitionsToWaitingForReview()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.SubmitForReviewAsync(id, DateTime.UtcNow, "the result", default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.WaitingForReview, t.Status);
Assert.Equal("the result", t.Result);
Assert.NotNull(t.FinishedAt);
}
[Fact]
public async Task SubmitForReviewAsync_FromQueued_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SubmitForReviewAsync(id, DateTime.UtcNow, "x", default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Queued, (await GetTaskAsync(id)).Status);
}
// ─── ApproveReviewAsync ───────────────────────────────────────────────
[Fact]
public async Task ApproveReviewAsync_FromWaitingForReview_TransitionsToDone()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
var result = await _sut.ApproveReviewAsync(id, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Done, (await GetTaskAsync(id)).Status);
}
[Fact]
public async Task ApproveReviewAsync_FromIdle_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.ApproveReviewAsync(id, default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.Idle, (await GetTaskAsync(id)).Status);
}
// ─── RejectToQueueAsync ───────────────────────────────────────────────
[Fact]
public async Task RejectToQueueAsync_StoresFeedback_AndQueues_AndWakes()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
var wakesBefore = _built.WakeCount();
var result = await _sut.RejectToQueueAsync(id, "please fix the bug", default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Queued, t.Status);
Assert.Equal("please fix the bug", t.ReviewFeedback);
Assert.True(_built.WakeCount() > wakesBefore);
}
[Fact]
public async Task RejectToQueueAsync_EmptyFeedback_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
var result = await _sut.RejectToQueueAsync(id, " ", default);
Assert.False(result.Ok);
Assert.Equal(TaskStatus.WaitingForReview, (await GetTaskAsync(id)).Status);
}
[Fact]
public async Task RejectToQueueAsync_FromIdle_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.RejectToQueueAsync(id, "feedback", default);
Assert.False(result.Ok);
}
// ─── RejectToIdleAsync ────────────────────────────────────────────────
[Fact]
public async Task RejectToIdleAsync_Parks_KeepsResult_ClearsFeedback()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview, result: "run output");
var result = await _sut.RejectToIdleAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Equal(TaskStatus.Idle, t.Status);
Assert.Equal("run output", t.Result);
Assert.Null(t.ReviewFeedback);
}
[Fact]
public async Task RejectToIdleAsync_FromRunning_Rejects()
{
var id = await SeedTaskAsync(TaskStatus.Running);
var result = await _sut.RejectToIdleAsync(id, default);
Assert.False(result.Ok);
}
// ─── ClearReviewFeedbackAsync ─────────────────────────────────────────
[Fact]
public async Task ClearReviewFeedbackAsync_RemovesFeedback_WithoutChangingStatus()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
await _sut.RejectToQueueAsync(id, "feedback", default); // sets feedback + Queued
var result = await _sut.ClearReviewFeedbackAsync(id, default);
Assert.True(result.Ok);
var t = await GetTaskAsync(id);
Assert.Null(t.ReviewFeedback);
Assert.Equal(TaskStatus.Queued, t.Status);
}
// ─── CancelAsync from WaitingForReview ────────────────────────────────
[Fact]
public async Task CancelAsync_FromWaitingForReview_TransitionsToCancelled()
{
var id = await SeedTaskAsync(TaskStatus.WaitingForReview);
var result = await _sut.CancelAsync(id, DateTime.UtcNow, default);
Assert.True(result.Ok);
Assert.Equal(TaskStatus.Cancelled, (await GetTaskAsync(id)).Status);
}
}

View File

@@ -9,8 +9,9 @@ public class TaskRowViewModelTests
{ {
[Theory] [Theory]
[InlineData(TaskStatus.Running, "running")] [InlineData(TaskStatus.Running, "running")]
[InlineData(TaskStatus.WaitingForReview, "review")]
[InlineData(TaskStatus.Failed, "error")] [InlineData(TaskStatus.Failed, "error")]
[InlineData(TaskStatus.Done, "review")] [InlineData(TaskStatus.Done, "done")]
[InlineData(TaskStatus.Queued, "queued")] [InlineData(TaskStatus.Queued, "queued")]
[InlineData(TaskStatus.Idle, "idle")] [InlineData(TaskStatus.Idle, "idle")]
public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected) public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected)

View File

@@ -42,6 +42,10 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null); public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
public Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
public Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
public Task CancelReviewAsync(string taskId) => 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 OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;