Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
869cf72abe | ||
|
|
f1715a34fa | ||
|
|
26998f05ff | ||
|
|
7db8f213d8 | ||
|
|
37738e3c8f | ||
|
|
81fd186fb2 | ||
|
|
3127930454 | ||
|
|
bed4255a5e | ||
|
|
dff06d9e35 | ||
|
|
0efad7a004 | ||
|
|
eaf27e8b3a | ||
|
|
13c3393e3a | ||
|
|
4704a28e5d | ||
|
|
1cb5171fba | ||
|
|
4684a0af76 | ||
|
|
6c27ffbdca | ||
|
|
21f1cf2a85 | ||
|
|
c88ed9d5eb | ||
|
|
9c1f20f2d9 | ||
|
|
e8d018dd54 | ||
|
|
1ca32a6bdd | ||
|
|
b86677d554 | ||
|
|
3e072fae66 | ||
|
|
4a36fbe5e0 | ||
|
|
9e5a3fe962 | ||
|
|
3f98fd0ae5 | ||
|
|
8420b87bd1 | ||
|
|
c0978df19a | ||
|
|
3ac9e030e2 | ||
|
|
4c6e6594dc | ||
|
|
5170914a7a | ||
|
|
b1f4349dab | ||
|
|
23326a1833 | ||
|
|
ca0594328a | ||
|
|
22d06acb35 | ||
|
|
ab44ba5e41 | ||
|
|
6c3afce329 | ||
|
|
f8e387bbc1 | ||
|
|
2a36998ac7 | ||
|
|
4148dcdb18 | ||
|
|
5783790733 |
@@ -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)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
175
docs/superpowers/plans/2026-06-01-waiting-for-review-state.md
Normal file
175
docs/superpowers/plans/2026-06-01-waiting-for-review-state.md
Normal 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).
|
||||
983
docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md
Normal file
983
docs/superpowers/plans/2026-06-02-prime-recurring-weekdays.md
Normal 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 + Mon–Fri 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 2–3 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 5–6, 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, // Mon–Fri
|
||||
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 Mon–Fri 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 Mon–Fri 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 (T7–T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4–T7), 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). ✓
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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. Mon–Fri) 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 = Mon–Fri).
|
||||
|
||||
### 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 "Mon–Fri" 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: Mon–Fri selected, time 07:00, enabled.
|
||||
- `Validate`: replace the `StartDate > EndDate` check with "at least one day must
|
||||
be selected"; keep the time-range (00:00–23: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` (Mon–Fri),
|
||||
`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. Mon–Fri
|
||||
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.
|
||||
@@ -4,11 +4,12 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## 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
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
## Repositories
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
@@ -19,9 +20,24 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
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)
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -22,6 +22,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.DefaultPermissionMode)
|
||||
.HasColumnName("default_permission_mode").IsRequired().HasDefaultValue("bypassPermissions");
|
||||
|
||||
builder.Property(s => s.MaxParallelExecutions)
|
||||
.HasColumnName("max_parallel_executions").IsRequired().HasDefaultValue(1);
|
||||
|
||||
builder.Property(s => s.WorktreeStrategy)
|
||||
.HasColumnName("worktree_strategy").IsRequired().HasDefaultValue("sibling");
|
||||
builder.Property(s => s.CentralWorktreeRoot)
|
||||
|
||||
@@ -16,6 +16,9 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
|
||||
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)
|
||||
.WithOne(c => c.List)
|
||||
|
||||
@@ -13,10 +13,9 @@ public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeSc
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||
|
||||
builder.Property(s => s.StartDate).HasColumnName("start_date").IsRequired();
|
||||
builder.Property(s => s.EndDate).HasColumnName("end_date").IsRequired();
|
||||
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.WorkdaysOnly).HasColumnName("workdays_only").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.PromptOverride).HasColumnName("prompt_override");
|
||||
|
||||
@@ -11,24 +11,26 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
private static string StatusToString(TaskStatus v)
|
||||
=> v switch
|
||||
{
|
||||
TaskStatus.Idle => "idle",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Done => "done",
|
||||
TaskStatus.Failed => "failed",
|
||||
TaskStatus.Cancelled => "cancelled",
|
||||
TaskStatus.Idle => "idle",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.WaitingForReview => "waiting_for_review",
|
||||
TaskStatus.Done => "done",
|
||||
TaskStatus.Failed => "failed",
|
||||
TaskStatus.Cancelled => "cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
private static TaskStatus StatusFromString(string v)
|
||||
=> v switch
|
||||
{
|
||||
"idle" => TaskStatus.Idle,
|
||||
"queued" => TaskStatus.Queued,
|
||||
"running" => TaskStatus.Running,
|
||||
"done" => TaskStatus.Done,
|
||||
"failed" => TaskStatus.Failed,
|
||||
"cancelled" => TaskStatus.Cancelled,
|
||||
"idle" => TaskStatus.Idle,
|
||||
"queued" => TaskStatus.Queued,
|
||||
"running" => TaskStatus.Running,
|
||||
"waiting_for_review" => TaskStatus.WaitingForReview,
|
||||
"done" => TaskStatus.Done,
|
||||
"failed" => TaskStatus.Failed,
|
||||
"cancelled" => TaskStatus.Cancelled,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(v)),
|
||||
};
|
||||
|
||||
@@ -72,6 +74,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||
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.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(t => t.StartedAt).HasColumnName("started_at");
|
||||
|
||||
600
src/ClaudeDo.Data/Migrations/20260601114247_AddListSortOrder.Designer.cs
generated
Normal file
600
src/ClaudeDo.Data/Migrations/20260601114247_AddListSortOrder.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
607
src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs
generated
Normal file
607
src/ClaudeDo.Data/Migrations/20260601133737_AddMaxParallelExecutions.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
607
src/ClaudeDo.Data/Migrations/20260601140000_NormalizeListIdFormat.Designer.cs
generated
Normal file
607
src/ClaudeDo.Data/Migrations/20260601140000_NormalizeListIdFormat.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
611
src/ClaudeDo.Data/Migrations/20260601150820_AddReviewFeedback.Designer.cs
generated
Normal file
611
src/ClaudeDo.Data/Migrations/20260601150820_AddReviewFeedback.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
603
src/ClaudeDo.Data/Migrations/20260602060000_PrimeWeekdays.Designer.cs
generated
Normal file
603
src/ClaudeDo.Data/Migrations/20260602060000_PrimeWeekdays.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/ClaudeDo.Data/Migrations/20260602060000_PrimeWeekdays.cs
Normal file
48
src/ClaudeDo.Data/Migrations/20260602060000_PrimeWeekdays.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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");
|
||||
@@ -89,6 +95,7 @@ namespace ClaudeDo.Data.Migrations
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
@@ -140,12 +147,21 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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);
|
||||
});
|
||||
|
||||
@@ -159,16 +175,18 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
@@ -177,20 +195,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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);
|
||||
@@ -327,6 +335,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
@@ -11,6 +11,8 @@ public sealed class AppSettingsEntity
|
||||
public int DefaultMaxTurns { get; set; } = 100;
|
||||
public string DefaultPermissionMode { get; set; } = "auto";
|
||||
|
||||
public int MaxParallelExecutions { get; set; } = 1;
|
||||
|
||||
public string WorktreeStrategy { get; set; } = "sibling";
|
||||
public string? CentralWorktreeRoot { get; set; }
|
||||
public bool WorktreeAutoCleanupEnabled { get; set; }
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class ListEntity
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
16
src/ClaudeDo.Data/Models/PrimeDays.cs
Normal file
16
src/ClaudeDo.Data/Models/PrimeDays.cs
Normal 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
|
||||
}
|
||||
@@ -3,10 +3,8 @@ namespace ClaudeDo.Data.Models;
|
||||
public sealed class PrimeScheduleEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
|
||||
public TimeSpan TimeOfDay { get; set; }
|
||||
public bool WorkdaysOnly { get; set; } = true;
|
||||
public bool Enabled { get; set; } = true;
|
||||
public DateTimeOffset? LastRunAt { get; set; }
|
||||
public string? PromptOverride { get; set; }
|
||||
|
||||
@@ -5,6 +5,7 @@ public enum TaskStatus
|
||||
Idle,
|
||||
Queued,
|
||||
Running,
|
||||
WaitingForReview,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
@@ -28,6 +29,7 @@ public sealed class TaskEntity
|
||||
public string? BlockedByTaskId { get; set; }
|
||||
public DateTime? ScheduledFor { get; set; }
|
||||
public string? Result { get; set; }
|
||||
public string? ReviewFeedback { get; set; }
|
||||
public string? LogPath { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class AppSettingsRepository
|
||||
row.DefaultMaxTurns = updated.DefaultMaxTurns;
|
||||
row.DefaultPermissionMode = string.IsNullOrWhiteSpace(updated.DefaultPermissionMode)
|
||||
? "auto" : updated.DefaultPermissionMode;
|
||||
row.MaxParallelExecutions = updated.MaxParallelExecutions < 1 ? 1 : updated.MaxParallelExecutions;
|
||||
row.WorktreeStrategy = string.IsNullOrWhiteSpace(updated.WorktreeStrategy) ? "sibling" : updated.WorktreeStrategy;
|
||||
row.CentralWorktreeRoot = string.IsNullOrWhiteSpace(updated.CentralWorktreeRoot)
|
||||
? null : updated.CentralWorktreeRoot;
|
||||
|
||||
@@ -33,7 +33,19 @@ public sealed class ListRepository
|
||||
|
||||
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)
|
||||
|
||||
@@ -12,10 +12,8 @@ public sealed class PrimeScheduleRepository
|
||||
|
||||
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking()
|
||||
.OrderBy(s => s.StartDate)
|
||||
.ToListAsync(ct);
|
||||
return rows.OrderBy(s => s.StartDate).ThenBy(s => s.TimeOfDay).ToList();
|
||||
var rows = await _context.PrimeSchedules.AsNoTracking().ToListAsync(ct);
|
||||
return rows.OrderBy(s => s.TimeOfDay).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrimeScheduleEntity?> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
@@ -30,10 +28,8 @@ public sealed class PrimeScheduleRepository
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.StartDate = entity.StartDate;
|
||||
existing.EndDate = entity.EndDate;
|
||||
existing.Days = entity.Days;
|
||||
existing.TimeOfDay = entity.TimeOfDay;
|
||||
existing.WorkdaysOnly = entity.WorkdaysOnly;
|
||||
existing.Enabled = entity.Enabled;
|
||||
existing.PromptOverride = entity.PromptOverride;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class DefaultListsSeeder
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
CreatedAt = now,
|
||||
});
|
||||
|
||||
@@ -209,6 +209,8 @@ public partial class App : Application
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
|
||||
sc.AddSingleton<IInstallStep, WriteConfigStep>();
|
||||
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
|
||||
sc.AddSingleton<RegisterMcpStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterMcpStep>());
|
||||
sc.AddSingleton<RegisterAutostartStep>();
|
||||
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
|
||||
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
|
||||
|
||||
@@ -32,4 +32,8 @@ public sealed class InstallContext
|
||||
|
||||
// InstallPage
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
{
|
||||
if (IsInstalling) return;
|
||||
|
||||
// Reset per-step state so a re-run starts clean instead of appending
|
||||
// output to the previous run's messages.
|
||||
foreach (var s in Steps)
|
||||
{
|
||||
s.Messages.Clear();
|
||||
s.Status = StepStatus.Pending;
|
||||
s.IsExpanded = false;
|
||||
}
|
||||
|
||||
IsInstalling = true;
|
||||
IsComplete = false;
|
||||
HasErrors = false;
|
||||
@@ -96,7 +105,11 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
|
||||
if (step is null) return;
|
||||
|
||||
step.Status = p.Status;
|
||||
// Status and output lines arrive on two separate Progress<T> channels, so a
|
||||
// trailing "Running" line-message can be delivered after the step's terminal
|
||||
// Done/Failed. Never let that downgrade a completed step back to Running.
|
||||
if (!(step.Status is StepStatus.Done or StepStatus.Failed && p.Status is StepStatus.Running))
|
||||
step.Status = p.Status;
|
||||
if (p.Message is not null)
|
||||
{
|
||||
// Messages starting with "\r" overwrite the previous line (live progress).
|
||||
@@ -135,6 +148,7 @@ public partial class InstallPageViewModel : ObservableObject, IInstallerPage
|
||||
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
|
||||
// Migrates the legacy service away and (re)registers the logon task.
|
||||
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
|
||||
_serviceProvider.GetRequiredService<RegisterMcpStep>(),
|
||||
_serviceProvider.GetRequiredService<StartWorkerStep>(),
|
||||
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
|
||||
// Refresh the bundled uninstaller exe + Add/Remove-Programs version so a
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
|
||||
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>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -24,6 +24,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
|
||||
[ObservableProperty] private string _heading = "Install ClaudeDo";
|
||||
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
|
||||
[ObservableProperty] private bool _installDirEditable = true;
|
||||
[ObservableProperty] private bool _registerMcp = true;
|
||||
|
||||
public WelcomePageViewModel(InstallContext context)
|
||||
{
|
||||
@@ -62,6 +63,7 @@ public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
|
||||
public Task ApplyAsync()
|
||||
{
|
||||
_context.InstallDirectory = InstallDirectory;
|
||||
_context.RegisterMcpWithClaude = RegisterMcp;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
47
src/ClaudeDo.Installer/Steps/RegisterMcpStep.cs
Normal file
47
src/ClaudeDo.Installer/Steps/RegisterMcpStep.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -7,25 +7,32 @@ namespace ClaudeDo.Installer.Steps;
|
||||
public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
public const string LegacyTaskName = "ClaudeDoWorker";
|
||||
public const string ProcessName = "ClaudeDo.Worker";
|
||||
|
||||
// Both must be stopped before the install dir is touched: a running app/worker
|
||||
// exe locks its directory, so Directory.Move during extraction would otherwise
|
||||
// fail with "Access to the path '...\app' is denied".
|
||||
private static readonly string[] ProcessNames = { "ClaudeDo.Worker", "ClaudeDo.App" };
|
||||
|
||||
public string Name => "Stop Worker";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Stopping worker process (if running)...");
|
||||
progress.Report("Stopping ClaudeDo processes (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
foreach (var p in Process.GetProcessesByName(ProcessName))
|
||||
foreach (var name in ProcessNames)
|
||||
{
|
||||
try
|
||||
foreach (var p in Process.GetProcessesByName(name))
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
return StepResult.Ok();
|
||||
|
||||
@@ -15,6 +15,8 @@ public class StatusColorConverter : IValueConverter
|
||||
{
|
||||
"queued" => Brushes.DodgerBlue,
|
||||
"running" => Brushes.Orange,
|
||||
"waitingforreview" => Brushes.MediumPurple,
|
||||
"waiting_for_review" => Brushes.MediumPurple,
|
||||
"done" => Brushes.Green,
|
||||
"failed" => Brushes.Red,
|
||||
"manual" => Brushes.Gray,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<!-- 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.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>
|
||||
<!-- 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>
|
||||
@@ -184,6 +185,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||
</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) -->
|
||||
<Style Selector="Border.chip.idle">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
@@ -826,6 +836,15 @@
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="TextDecorations" Value="Strikethrough" />
|
||||
</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) -->
|
||||
@@ -1016,4 +1035,23 @@
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</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>
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<SolidColorBrush x:Key="StatusErrorBrush" Color="{StaticResource StatusErrorColor}" />
|
||||
<SolidColorBrush x:Key="StatusQueuedBrush" Color="{StaticResource StatusQueuedColor}" />
|
||||
<SolidColorBrush x:Key="StatusIdleBrush" Color="{StaticResource StatusIdleColor}" />
|
||||
<SolidColorBrush x:Key="StatusDoneBrush" Color="#6FA86B" />
|
||||
|
||||
<!-- Subtle white overlay (island hairline border) -->
|
||||
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
|
||||
@@ -96,6 +97,8 @@
|
||||
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
|
||||
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
|
||||
<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) -->
|
||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
|
||||
@@ -33,6 +33,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
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 OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -2,10 +2,8 @@ namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record PrimeScheduleDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
int Days,
|
||||
TimeSpan TimeOfDay,
|
||||
bool WorkdaysOnly,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastRunAt,
|
||||
string? PromptOverride);
|
||||
|
||||
@@ -349,6 +349,26 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
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)
|
||||
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
|
||||
@@ -450,6 +470,7 @@ public sealed record AppSettingsDto(
|
||||
string DefaultModel,
|
||||
int DefaultMaxTurns,
|
||||
string DefaultPermissionMode,
|
||||
int MaxParallelExecutions,
|
||||
string WorktreeStrategy,
|
||||
string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled,
|
||||
|
||||
@@ -840,6 +840,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
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]
|
||||
private async System.Threading.Tasks.Task AddSubtaskAsync()
|
||||
{
|
||||
@@ -943,6 +972,7 @@ public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
public required string Id { get; init; }
|
||||
[ObservableProperty] private string _title = "";
|
||||
[ObservableProperty] private bool _done;
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.TaskStatus _status;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState _worktreeState = ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
public string? IconKey { get; init; }
|
||||
public string? DotColorKey { get; init; }
|
||||
}
|
||||
|
||||
@@ -82,6 +82,52 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||
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> SmartLists { 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)
|
||||
{
|
||||
foreach (var i in Items) i.IsActive = ReferenceEquals(i, value);
|
||||
|
||||
@@ -17,7 +17,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private PlanningPhase _planningPhase;
|
||||
[ObservableProperty] private string? _branch;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private string? _liveTail;
|
||||
[ObservableProperty] private DateTime? _scheduledFor;
|
||||
[ObservableProperty] private int _diffAdditions;
|
||||
[ObservableProperty] private int _diffDeletions;
|
||||
@@ -64,39 +63,43 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !HasQueuedSubtasks
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized);
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
&& PlanningPhase == PlanningPhase.Finalized
|
||||
&& !HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
|
||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
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
|
||||
{
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.Failed, _) => "error",
|
||||
(TaskStatus.Done, _) => "review",
|
||||
(TaskStatus.Queued, true) => "waiting",
|
||||
(TaskStatus.Queued, false) => "queued",
|
||||
_ => "idle",
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.WaitingForReview, _) => "review",
|
||||
(TaskStatus.Failed, _) => "error",
|
||||
(TaskStatus.Done, _) => "done",
|
||||
(TaskStatus.Queued, true) => "waiting",
|
||||
(TaskStatus.Queued, false) => "queued",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
partial void OnStatusChanged(TaskStatus value)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(HasLiveTail));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
@@ -152,7 +155,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
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 OnScheduledForChanged(DateTime? value)
|
||||
{
|
||||
|
||||
@@ -56,18 +56,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
{
|
||||
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.TaskMessageEvent += OnWorkerTaskMessage;
|
||||
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
||||
_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)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
{
|
||||
if (row is null) return;
|
||||
|
||||
@@ -9,6 +9,7 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _defaultModel = ModelRegistry.DefaultAlias;
|
||||
[ObservableProperty] private int _defaultMaxTurns = 100;
|
||||
[ObservableProperty] private string _defaultPermissionMode = PermissionModeRegistry.DefaultMode;
|
||||
[ObservableProperty] private int _maxParallelExecutions = 1;
|
||||
|
||||
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
||||
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
||||
@@ -17,6 +18,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
|
||||
{
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
if (r.StartDate > r.EndDate)
|
||||
return $"Schedule {r.TimeOfDay:hh\\:mm}: start date is after end date.";
|
||||
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.";
|
||||
}
|
||||
@@ -52,13 +52,10 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private void AddSchedule()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var dto = new PrimeScheduleDto(
|
||||
Id: Guid.NewGuid(),
|
||||
StartDate: today,
|
||||
EndDate: today.AddDays(30),
|
||||
Days: 31, // Mon–Fri
|
||||
TimeOfDay: new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly: true,
|
||||
Enabled: true,
|
||||
LastRunAt: null,
|
||||
PromptOverride: null);
|
||||
|
||||
@@ -5,14 +5,20 @@ 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 DateOnly _startDate;
|
||||
[ObservableProperty] private DateOnly _endDate;
|
||||
[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 bool _workdaysOnly;
|
||||
[ObservableProperty] private DateTimeOffset? _lastRunAt;
|
||||
|
||||
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
|
||||
@@ -24,13 +30,30 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
||||
Id = dto.Id;
|
||||
IsExisting = isExisting;
|
||||
Enabled = dto.Enabled;
|
||||
StartDate = dto.StartDate;
|
||||
EndDate = dto.EndDate;
|
||||
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;
|
||||
WorkdaysOnly = dto.WorkdaysOnly;
|
||||
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, StartDate, EndDate, TimeOfDay, WorkdaysOnly, Enabled, LastRunAt, null);
|
||||
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.DefaultModel = dto.DefaultModel ?? "sonnet";
|
||||
General.DefaultMaxTurns = dto.DefaultMaxTurns;
|
||||
General.DefaultPermissionMode = dto.DefaultPermissionMode ?? "auto";
|
||||
General.MaxParallelExecutions = dto.MaxParallelExecutions;
|
||||
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
|
||||
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
|
||||
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
|
||||
@@ -69,6 +70,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.DefaultModel ?? "sonnet",
|
||||
General.DefaultMaxTurns,
|
||||
General.DefaultPermissionMode ?? "auto",
|
||||
General.MaxParallelExecutions,
|
||||
Worktrees.WorktreeStrategy ?? "sibling",
|
||||
string.IsNullOrWhiteSpace(Worktrees.CentralWorktreeRoot) ? null : Worktrees.CentralWorktreeRoot,
|
||||
Worktrees.WorktreeAutoCleanupEnabled,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -23,16 +22,49 @@ public class ModalShell : ContentControl
|
||||
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, 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)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && VisualRoot is Window w)
|
||||
w.BeginMoveDrag(e);
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@
|
||||
<StackPanel Grid.Column="1" Spacing="0">
|
||||
<TextBlock Classes="meta"
|
||||
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}"
|
||||
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
@@ -186,13 +189,30 @@
|
||||
Width="16" Height="16"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="subtask-title"
|
||||
Text="{Binding Title}"
|
||||
<Panel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="subtask-title"
|
||||
Text="{Binding Title}"
|
||||
IsVisible="{Binding !IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Cursor="Ibeam"
|
||||
Tapped="OnSubtaskTitleTapped"/>
|
||||
<TextBox Classes="subtask-edit"
|
||||
Text="{Binding Title, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"/>
|
||||
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>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
@@ -135,6 +139,31 @@ public partial class DetailsIslandView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
|
||||
@@ -113,8 +113,18 @@
|
||||
<ItemsControl ItemsSource="{Binding UserLists}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:ListNavItemViewModel">
|
||||
<Border Classes="list-item" Classes.active="{Binding IsActive}"
|
||||
Tapped="OnItemTapped">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto">
|
||||
|
||||
<!-- 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>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Settings..."
|
||||
@@ -123,6 +133,15 @@
|
||||
<MenuItem Header="Worktrees…"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
|
||||
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>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
@@ -152,6 +171,12 @@
|
||||
Text="{Binding Count}"/>
|
||||
</Grid>
|
||||
</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>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -13,9 +15,13 @@ namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
public partial class ListsIslandView : UserControl
|
||||
{
|
||||
private static readonly DataFormat<string> ListRowFormat =
|
||||
DataFormat.CreateStringApplicationFormat("claudedo-list-row");
|
||||
|
||||
public ListsIslandView()
|
||||
{
|
||||
InitializeComponent();
|
||||
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is ListsIslandViewModel vm)
|
||||
@@ -84,6 +90,127 @@ public partial class ListsIslandView : UserControl
|
||||
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)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Specialized;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
@@ -9,16 +8,29 @@ public partial class SessionTerminalView : UserControl
|
||||
{
|
||||
public SessionTerminalView() { InitializeComponent(); }
|
||||
|
||||
private DetailsIslandViewModel? _boundVm;
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
if (DataContext is DetailsIslandViewModel vm)
|
||||
vm.Log.CollectionChanged += OnLogChanged;
|
||||
if (_boundVm is not null)
|
||||
_boundVm.Log.CollectionChanged -= OnLogChanged;
|
||||
_boundVm = DataContext as DetailsIslandViewModel;
|
||||
if (_boundVm is not null)
|
||||
_boundVm.Log.CollectionChanged += OnLogChanged;
|
||||
}
|
||||
|
||||
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +131,30 @@
|
||||
<!-- Status chip -->
|
||||
<Border Classes="chip"
|
||||
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.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}">
|
||||
<TextBlock Text="{Binding Status}"/>
|
||||
<TextBlock Text="{Binding StatusLabel}"/>
|
||||
</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) -->
|
||||
<Button Classes="icon-btn dequeue-btn"
|
||||
IsVisible="{Binding CanRemoveFromQueue}"
|
||||
@@ -175,20 +193,6 @@
|
||||
</Border>
|
||||
|
||||
</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>
|
||||
|
||||
<!-- Star toggle -->
|
||||
@@ -241,5 +245,36 @@
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</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 & 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>
|
||||
</UserControl>
|
||||
|
||||
@@ -88,6 +88,43 @@ public partial class TaskRowView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
|
||||
@@ -126,8 +126,17 @@
|
||||
<Binding Path="IsShowingCompleted"/>
|
||||
</MultiBinding>
|
||||
</StackPanel.IsVisible>
|
||||
<TextBlock Classes="eyebrow section-label"
|
||||
Text="{Binding CompletedHeader}" Margin="14,14,14,6"/>
|
||||
<Grid ColumnDefinitions="*,Auto" 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.ItemTemplate>
|
||||
<DataTemplate DataType="vm:TaskRowViewModel">
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<PathIcon Data="{StaticResource Icon.WinMin}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<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 Classes="title-ctrl close" Click="OnClose">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="10" Height="10"/>
|
||||
|
||||
@@ -19,6 +19,21 @@ public partial class MainWindow : Window
|
||||
InitializeComponent();
|
||||
KeyDown += OnWindowKeyDown;
|
||||
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)
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.DiffModalView"
|
||||
x:DataType="vm:DiffModalViewModel"
|
||||
Title="Diff"
|
||||
Width="1200" Height="800"
|
||||
WindowDecorations="None"
|
||||
Width="1200" Height="800" MinWidth="700" MinHeight="450"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
Width="520" Height="720"
|
||||
CanResize="True"
|
||||
MinWidth="460" MinHeight="520"
|
||||
WindowDecorations="None"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.MergeModalView"
|
||||
x:DataType="vm:MergeModalViewModel"
|
||||
Title="Merge worktree"
|
||||
Width="560" Height="460"
|
||||
CanResize="False"
|
||||
WindowDecorations="None"
|
||||
Width="560" Height="460" MinWidth="460" MinHeight="360"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
|
||||
x:DataType="vm:RepoImportModalViewModel"
|
||||
Title="Add repos as lists"
|
||||
Width="560" Height="480"
|
||||
WindowDecorations="None"
|
||||
Width="560" Height="480" MinWidth="420" MinHeight="320"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:conv="using:ClaudeDo.Ui.Converters"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.SettingsModalView"
|
||||
x:DataType="vm:SettingsModalViewModel"
|
||||
Title="Settings"
|
||||
Width="580" Height="760"
|
||||
WindowDecorations="None"
|
||||
Width="580" Height="760" MinWidth="480" MinHeight="520"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
@@ -17,10 +18,6 @@
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</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.Footer>
|
||||
@@ -73,6 +70,14 @@
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</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>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
@@ -171,29 +176,31 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<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.ItemTemplate>
|
||||
<DataTemplate x:DataType="settings:PrimeScheduleRowViewModel">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||||
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"/>
|
||||
<ctl:ThemedDatePicker Grid.Column="1"
|
||||
IsRange="True"
|
||||
StartDate="{Binding StartDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||||
EndDate="{Binding EndDate, Mode=TwoWay, Converter={StaticResource DateOnlyToDateTime}}"
|
||||
Watermark="Pick a range"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBox Grid.Column="2" Width="64"
|
||||
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
|
||||
VerticalAlignment="Center"/>
|
||||
<CheckBox Grid.Column="3" Content="Mon–Fri"
|
||||
IsChecked="{Binding WorkdaysOnly, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="meta" Grid.Column="4" Text="{Binding LastRunLabel}" 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>
|
||||
<TimePicker Grid.Column="2"
|
||||
SelectedTime="{Binding TimeOfDay, Mode=TwoWay}"
|
||||
ClockIdentifier="24HourClock" MinuteIncrement="5"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||
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}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Grid>
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
|
||||
<!-- Title strip -->
|
||||
<Border DockPanel.Dock="Top" Height="36"
|
||||
PointerPressed="OnTitleBarPressed">
|
||||
Background="Transparent"
|
||||
PointerPressed="OnTitleBarPressed"
|
||||
PointerMoved="OnTitleBarMoved"
|
||||
PointerReleased="OnTitleBarReleased">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
|
||||
<TextBlock Grid.Column="0" Text="Worktree" VerticalAlignment="Center"
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeBody}"
|
||||
|
||||
@@ -66,9 +66,31 @@ public partial class WorktreeModalView : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private PixelPoint _dragStartScreen;
|
||||
private PixelPoint _dragStartPos;
|
||||
private bool _dragging;
|
||||
|
||||
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True">
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1">
|
||||
|
||||
<Window.Resources>
|
||||
<converters:WorktreeStateColorConverter x:Key="WorktreeStateColor"/>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
x:DataType="vm:ConflictResolutionViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
||||
Title="Merge conflict"
|
||||
Width="560" SizeToContent="Height"
|
||||
WindowDecorations="None"
|
||||
Width="560" SizeToContent="Height" MinWidth="460"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Planning.PlanningDiffView"
|
||||
x:DataType="vm:PlanningDiffViewModel"
|
||||
Title="Planning — Combined diff"
|
||||
Width="1100" Height="700"
|
||||
WindowDecorations="None"
|
||||
Width="1100" Height="700" MinWidth="700" MinHeight="450"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
|
||||
|
||||
@@ -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).
|
||||
- **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:
|
||||
- `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`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
||||
- `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 |
|
||||
|---|---|---|
|
||||
| `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`. |
|
||||
| `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`):
|
||||
|
||||
```
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
Running → Done | Failed | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
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)
|
||||
Failed → 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
|
||||
|
||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||
@@ -100,7 +104,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
|
||||
## 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`
|
||||
|
||||
|
||||
371
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
371
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
@@ -1,15 +1,24 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
||||
public sealed record 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(
|
||||
string Id,
|
||||
@@ -23,6 +32,23 @@ public sealed record TaskDto(
|
||||
DateTime? StartedAt,
|
||||
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]
|
||||
public sealed class ExternalMcpService
|
||||
{
|
||||
@@ -31,19 +57,31 @@ public sealed class ExternalMcpService
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly GitService _git;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _maintenance;
|
||||
private readonly TaskMergeService _merge;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state)
|
||||
ITaskStateService state,
|
||||
GitService git,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService maintenance,
|
||||
TaskMergeService merge)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_git = git;
|
||||
_dbFactory = dbFactory;
|
||||
_maintenance = maintenance;
|
||||
_merge = merge;
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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(
|
||||
string listId,
|
||||
string? createdBy,
|
||||
@@ -64,7 +104,8 @@ public sealed class ExternalMcpService
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -78,7 +119,11 @@ public sealed class ExternalMcpService
|
||||
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)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -90,17 +135,15 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> AddTask(
|
||||
string listId,
|
||||
string title,
|
||||
string? description,
|
||||
string createdBy,
|
||||
bool queueImmediately,
|
||||
CancellationToken cancellationToken)
|
||||
string? description = null,
|
||||
string? createdBy = null,
|
||||
bool queueImmediately = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
throw new InvalidOperationException("listId is required.");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
if (string.IsNullOrWhiteSpace(createdBy))
|
||||
throw new InvalidOperationException("createdBy is required.");
|
||||
|
||||
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
@@ -114,13 +157,12 @@ public sealed class ExternalMcpService
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy,
|
||||
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (queueImmediately)
|
||||
{
|
||||
// Routes through TaskStateService so the queue is woken automatically.
|
||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||
if (!enqueue.Ok)
|
||||
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
||||
@@ -154,14 +196,19 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
string taskId,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -181,13 +228,45 @@ public sealed class ExternalMcpService
|
||||
|
||||
default:
|
||||
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))!;
|
||||
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).")]
|
||||
public async Task RunTaskNow(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -206,16 +285,16 @@ public sealed class ExternalMcpService
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Cancel a running task. Returns true if the task was running and cancellation was requested.")]
|
||||
public async Task<bool> CancelTask(string taskId, CancellationToken cancellationToken)
|
||||
[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<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var cancelled = _queue.CancelTask(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.")]
|
||||
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
[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<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -224,6 +303,258 @@ public sealed class ExternalMcpService
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
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(
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed record RunDto(
|
||||
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
|
||||
DateTime? StartedAt, DateTime? FinishedAt);
|
||||
|
||||
public sealed record TaskLogResult(
|
||||
bool Available,
|
||||
IReadOnlyList<string> Entries,
|
||||
int TotalLines,
|
||||
bool Truncated);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class RunHistoryMcpTools
|
||||
{
|
||||
@@ -33,26 +39,68 @@ public sealed class RunHistoryMcpTools
|
||||
return ToDto(run);
|
||||
}
|
||||
|
||||
private const int MaxLogBytes = 256 * 1024;
|
||||
|
||||
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
|
||||
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
|
||||
[McpServerTool, Description(
|
||||
"Fetch log entries from a task's latest run. " +
|
||||
"Returns { available, entries, totalLines, truncated }. " +
|
||||
"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)
|
||||
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
|
||||
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||
throw new InvalidOperationException("No log available for the latest run.");
|
||||
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
|
||||
if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||
return new TaskLogResult(false, [], 0, false);
|
||||
|
||||
var totalBytes = new FileInfo(run.LogPath).Length;
|
||||
if (totalBytes <= MaxLogBytes)
|
||||
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
||||
string allText;
|
||||
try
|
||||
{
|
||||
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];
|
||||
await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin);
|
||||
var read = await fs.ReadAsync(buffer, cancellationToken);
|
||||
var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read);
|
||||
return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}";
|
||||
var lines = allText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var totalLines = lines.Length;
|
||||
|
||||
IReadOnlyList<string> entries;
|
||||
bool truncated;
|
||||
|
||||
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(
|
||||
|
||||
@@ -22,6 +22,7 @@ public record AppSettingsDto(
|
||||
string DefaultModel,
|
||||
int DefaultMaxTurns,
|
||||
string DefaultPermissionMode,
|
||||
int MaxParallelExecutions,
|
||||
string WorktreeStrategy,
|
||||
string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled,
|
||||
@@ -202,6 +203,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
row.DefaultModel,
|
||||
row.DefaultMaxTurns,
|
||||
row.DefaultPermissionMode,
|
||||
row.MaxParallelExecutions,
|
||||
row.WorktreeStrategy,
|
||||
row.CentralWorktreeRoot,
|
||||
row.WorktreeAutoCleanupEnabled,
|
||||
@@ -219,6 +221,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
DefaultModel = dto.DefaultModel ?? ModelRegistry.DefaultAlias,
|
||||
DefaultMaxTurns = dto.DefaultMaxTurns,
|
||||
DefaultPermissionMode = dto.DefaultPermissionMode ?? PermissionModeRegistry.DefaultMode,
|
||||
MaxParallelExecutions = dto.MaxParallelExecutions,
|
||||
WorktreeStrategy = dto.WorktreeStrategy ?? "sibling",
|
||||
CentralWorktreeRoot = dto.CentralWorktreeRoot,
|
||||
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");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -463,8 +490,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
|
||||
return rows.Select(e => new PrimeScheduleDto(
|
||||
e.Id, e.StartDate, e.EndDate, e.TimeOfDay,
|
||||
e.WorkdaysOnly, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
|
||||
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
|
||||
StartDate = dto.StartDate,
|
||||
EndDate = dto.EndDate,
|
||||
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
|
||||
TimeOfDay = dto.TimeOfDay,
|
||||
WorkdaysOnly = dto.WorkdaysOnly,
|
||||
Enabled = dto.Enabled,
|
||||
PromptOverride = dto.PromptOverride,
|
||||
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
|
||||
@@ -486,8 +510,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
};
|
||||
await repo.UpsertAsync(entity);
|
||||
_primeSignal.Signal();
|
||||
return new PrimeScheduleDto(entity.Id, entity.StartDate, entity.EndDate, entity.TimeOfDay,
|
||||
entity.WorkdaysOnly, entity.Enabled, entity.LastRunAt, entity.PromptOverride);
|
||||
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
|
||||
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
|
||||
}
|
||||
|
||||
public async Task DeletePrimeSchedule(Guid id)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
|
||||
@@ -22,30 +24,24 @@ public static class NextDueCalculator
|
||||
|
||||
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 alreadyFiredToday = s.LastRunAt is { } last &&
|
||||
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);
|
||||
if (todayTarget >= now)
|
||||
return new NextDue(s, todayTarget, false);
|
||||
if (now <= todayTarget + catchUp)
|
||||
return new NextDue(s, now, true);
|
||||
}
|
||||
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);
|
||||
if (s.StartDate > d) d = s.StartDate;
|
||||
for (int i = 0; i < 8; i++)
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
if (d > s.EndDate) return null;
|
||||
if (IsEligibleDay(s, d))
|
||||
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
|
||||
d = d.AddDays(1);
|
||||
@@ -53,13 +49,20 @@ public static class NextDueCalculator
|
||||
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;
|
||||
if (!s.WorkdaysOnly) return true;
|
||||
var dow = d.ToDateTime(TimeOnly.MinValue).DayOfWeek;
|
||||
return dow != DayOfWeek.Saturday && dow != DayOfWeek.Sunday;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -2,10 +2,8 @@ namespace ClaudeDo.Worker.Prime;
|
||||
|
||||
public sealed record PrimeScheduleDto(
|
||||
Guid Id,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
int Days,
|
||||
TimeSpan TimeOfDay,
|
||||
bool WorkdaysOnly,
|
||||
bool Enabled,
|
||||
DateTimeOffset? LastRunAt,
|
||||
string? PromptOverride);
|
||||
|
||||
@@ -102,7 +102,7 @@ public sealed class PrimeScheduler : BackgroundService
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -204,6 +204,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
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<ListMcpTools>();
|
||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||
|
||||
@@ -3,7 +3,9 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Queue;
|
||||
|
||||
@@ -16,9 +18,10 @@ public sealed class QueueService : BackgroundService
|
||||
private readonly QueueWaker _waker;
|
||||
private readonly IQueuePicker _picker;
|
||||
private readonly OverrideSlotService _override;
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private volatile QueueSlotState? _queueSlot;
|
||||
private readonly Dictionary<string, QueueSlotState> _queueSlots = new();
|
||||
|
||||
public QueueService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
@@ -27,7 +30,8 @@ public sealed class QueueService : BackgroundService
|
||||
ILogger<QueueService> logger,
|
||||
QueueWaker waker,
|
||||
IQueuePicker picker,
|
||||
OverrideSlotService overrideSlot)
|
||||
OverrideSlotService overrideSlot,
|
||||
ITaskStateService state)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_runner = runner;
|
||||
@@ -36,13 +40,17 @@ public sealed class QueueService : BackgroundService
|
||||
_waker = waker;
|
||||
_picker = picker;
|
||||
_override = overrideSlot;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string slot, string taskId, DateTime startedAt)> GetActive()
|
||||
{
|
||||
var list = new List<(string, string, DateTime)>();
|
||||
var q = _queueSlot;
|
||||
if (q is not null) list.Add(("queue", q.TaskId, q.StartedAt));
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var slot in _queueSlots.Values)
|
||||
list.Add(("queue", slot.TaskId, slot.StartedAt));
|
||||
}
|
||||
var o = _override.CurrentSlot;
|
||||
if (o is not null) list.Add(("override", o.TaskId, o.StartedAt));
|
||||
return list;
|
||||
@@ -64,7 +72,7 @@ public sealed class QueueService : BackgroundService
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queueSlot?.TaskId == taskId)
|
||||
if (_queueSlots.ContainsKey(taskId))
|
||||
throw new InvalidOperationException("task is already running in queue slot");
|
||||
}
|
||||
}
|
||||
@@ -75,9 +83,9 @@ public sealed class QueueService : BackgroundService
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -100,26 +108,33 @@ public sealed class QueueService : BackgroundService
|
||||
|
||||
await Task.WhenAny(wakeTask, timerTask);
|
||||
|
||||
if (_queueSlot is not null) continue;
|
||||
var maxParallel = await GetMaxParallelAsync(stoppingToken);
|
||||
|
||||
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (task is null) continue;
|
||||
|
||||
lock (_lock)
|
||||
// Fill as many free slots as the limit allows.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_queueSlot is not null) continue;
|
||||
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
lock (_lock)
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlot = null; }
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
if (_queueSlots.Count >= maxParallel) break;
|
||||
}
|
||||
|
||||
var task = await _picker.ClaimNextAsync(DateTime.UtcNow, stoppingToken);
|
||||
if (task is null) break;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
_queueSlots[task.Id] = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
|
||||
|
||||
_ = RunInSlotAsync(task.Id, cts.Token).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
|
||||
lock (_lock) { _queueSlots.Remove(task.Id); }
|
||||
cts.Dispose();
|
||||
_waker.Wake(); // Check for next task immediately.
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
@@ -135,6 +150,21 @@ public sealed class QueueService : BackgroundService
|
||||
_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)
|
||||
{
|
||||
try
|
||||
@@ -149,6 +179,39 @@ public sealed class QueueService : BackgroundService
|
||||
?? 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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -44,6 +44,14 @@ public sealed class StreamAnalyzer
|
||||
_structuredOutputJson = structuredProp.ToString();
|
||||
if (root.TryGetProperty("session_id", out var sessionProp))
|
||||
_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;
|
||||
|
||||
case "assistant":
|
||||
@@ -66,7 +74,7 @@ public sealed class StreamAnalyzer
|
||||
|
||||
public StreamResult GetResult() => new()
|
||||
{
|
||||
ResultMarkdown = _resultMarkdown,
|
||||
ResultMarkdown = FallbackResult(),
|
||||
StructuredOutputJson = _structuredOutputJson,
|
||||
SessionId = _sessionId,
|
||||
TurnCount = _turnCount,
|
||||
@@ -75,6 +83,20 @@ public sealed class StreamAnalyzer
|
||||
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)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var eventProp)) return;
|
||||
|
||||
@@ -238,6 +238,11 @@ public sealed class TaskRunner
|
||||
{
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
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);
|
||||
@@ -277,9 +282,6 @@ public sealed class TaskRunner
|
||||
{
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
await runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -296,9 +298,6 @@ public sealed class TaskRunner
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
await runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
@@ -323,10 +322,22 @@ public sealed class TaskRunner
|
||||
// Terminal DB write uses CancellationToken.None so the task status
|
||||
// is never left as 'running' because of a cancel that arrived
|
||||
// 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;
|
||||
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);
|
||||
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 _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||
}
|
||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
|
||||
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, 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> CancelAsync(string taskId, DateTime finishedAt, 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> StartPlanningAsync(string parentId, CancellationToken ct);
|
||||
|
||||
@@ -90,6 +90,90 @@ public sealed class TaskStateService : ITaskStateService
|
||||
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)
|
||||
{
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
@@ -116,7 +200,8 @@ public sealed class TaskStateService : ITaskStateService
|
||||
{
|
||||
var affected = await ctx.Tasks
|
||||
.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
|
||||
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||
|
||||
@@ -40,6 +40,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => 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 OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
@@ -19,13 +19,14 @@ public class PrimeClaudeTabViewModelTests
|
||||
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(new PrimeScheduleDto(
|
||||
Guid.NewGuid(), new DateOnly(2026,5,1), new DateOnly(2026,5,31),
|
||||
new TimeSpan(7,0,0), true, true, null, null));
|
||||
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
|
||||
var vm = new PrimeClaudeTabViewModel(api);
|
||||
await vm.LoadAsync();
|
||||
Assert.Single(vm.Rows);
|
||||
@@ -38,8 +39,21 @@ public class PrimeClaudeTabViewModelTests
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
Assert.Single(vm.Rows);
|
||||
Assert.True(vm.Rows[0].Enabled);
|
||||
Assert.True(vm.Rows[0].WorkdaysOnly);
|
||||
Assert.Equal(new TimeSpan(7,0,0), vm.Rows[0].TimeOfDay);
|
||||
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]
|
||||
@@ -48,8 +62,8 @@ public class PrimeClaudeTabViewModelTests
|
||||
var api = new FakeApi();
|
||||
var keptId = 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(new PrimeScheduleDto(deletedId, new(2026,5,1), new(2026,5,31), new(8,0,0), true, true, null, null));
|
||||
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();
|
||||
@@ -63,12 +77,20 @@ public class PrimeClaudeTabViewModelTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Reports_StartAfterEnd()
|
||||
public void Validate_Reports_No_Days_Selected()
|
||||
{
|
||||
var vm = new PrimeClaudeTabViewModel(new FakeApi());
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
vm.Rows[0].StartDate = new DateOnly(2026, 6, 1);
|
||||
vm.Rows[0].EndDate = new DateOnly(2026, 5, 1);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -87,9 +89,17 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||
private ExternalMcpService BuildSut(QueueService queue)
|
||||
{
|
||||
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()
|
||||
{
|
||||
@@ -107,12 +117,13 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
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 picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||
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]
|
||||
@@ -160,6 +171,54 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
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]
|
||||
public async Task DeleteTask_RemovesTask()
|
||||
{
|
||||
|
||||
@@ -54,13 +54,16 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_NoLog_Throws()
|
||||
public async Task GetTaskLog_NoRun_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
Assert.Equal(0, result.TotalLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,24 +72,26 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.Equal("hello log", content);
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal(3, result.TotalLines);
|
||||
Assert.Contains("line1", result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +102,7 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_Throws()
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
@@ -107,22 +112,22 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = null,
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail()
|
||||
public async Task GetTaskLog_ManyLines_DefaultTailReturnsLast50()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
||||
|
||||
// Write 300 KB so it exceeds the 256 KB cap
|
||||
var chunk = new string('A', 1024);
|
||||
await using (var w = new StreamWriter(logPath, append: false))
|
||||
for (var i = 0; i < 300; i++)
|
||||
await w.WriteAsync(chunk);
|
||||
// Write 108 lines (the observed real-world size that exceeded token limits)
|
||||
var lines = Enumerable.Range(1, 108).Select(i => $"{{\"line\":{i}}}");
|
||||
await File.WriteAllLinesAsync(logPath, lines);
|
||||
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
@@ -130,17 +135,84 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.StartsWith("[truncated:", content);
|
||||
Assert.True(content.Length < 300 * 1024);
|
||||
Assert.True(result.Available);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Prime;
|
||||
@@ -5,26 +6,34 @@ namespace ClaudeDo.Worker.Tests.Prime;
|
||||
public class NextDueCalculatorTests
|
||||
{
|
||||
private static PrimeScheduleDto Schedule(
|
||||
DateOnly start, DateOnly end, TimeSpan time,
|
||||
bool workdaysOnly = true, bool enabled = true, DateTimeOffset? lastRun = null) =>
|
||||
new(Guid.NewGuid(), start, end, time, workdaysOnly, enabled, lastRun, null);
|
||||
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(new(2026,5,1), new(2026,5,31), new(7,0,0), enabled: false);
|
||||
Assert.Null(NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30)));
|
||||
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));
|
||||
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
|
||||
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
|
||||
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.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
|
||||
Assert.False(r.FireImmediately);
|
||||
}
|
||||
|
||||
@@ -32,8 +41,8 @@ public class NextDueCalculatorTests
|
||||
public void Within_CatchUp_Window_Fires_Immediately()
|
||||
{
|
||||
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 r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
|
||||
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);
|
||||
}
|
||||
@@ -41,50 +50,53 @@ public class NextDueCalculatorTests
|
||||
[Fact]
|
||||
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2));
|
||||
var s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0));
|
||||
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
|
||||
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));
|
||||
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[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 s = Schedule(new(2026,5,1), new(2026,5,31), new(7,0,0), workdaysOnly: true);
|
||||
var r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
|
||||
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));
|
||||
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
|
||||
}
|
||||
|
||||
[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 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 r = NextDueCalculator.Compute(new[]{s}, now, TimeSpan.FromMinutes(30));
|
||||
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 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)));
|
||||
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(new(2026,5,1), new(2026,5,31), new(7,0,0));
|
||||
var late = Schedule(new(2026,5,1), new(2026,5,31), new(9,0,0));
|
||||
var r = NextDueCalculator.Compute(new[]{late, early}, now, TimeSpan.FromMinutes(30));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,8 @@ public class PrimeSchedulerTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
Days = PrimeDays.All,
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
@@ -86,10 +84,8 @@ public class PrimeSchedulerTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
Days = PrimeDays.All,
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
@@ -128,10 +124,8 @@ public class PrimeSchedulerTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 5),
|
||||
EndDate = new DateOnly(2026, 5, 5),
|
||||
Days = PrimeDays.All,
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = false,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
@@ -19,10 +19,8 @@ public class PrimeScheduleRepositoryTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 1),
|
||||
EndDate = new DateOnly(2026, 6, 30),
|
||||
Days = PrimeDays.Weekdays,
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
WorkdaysOnly = true,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
@@ -33,6 +31,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
|
||||
Assert.Single(rows);
|
||||
Assert.Equal(id, rows[0].Id);
|
||||
Assert.Equal(new TimeSpan(7, 0, 0), rows[0].TimeOfDay);
|
||||
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -45,8 +44,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 1),
|
||||
EndDate = new DateOnly(2026, 5, 31),
|
||||
Days = PrimeDays.Weekdays,
|
||||
TimeOfDay = new TimeSpan(7, 0, 0),
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
@@ -69,8 +67,7 @@ public class PrimeScheduleRepositoryTests : IDisposable
|
||||
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
|
||||
{
|
||||
Id = id,
|
||||
StartDate = new DateOnly(2026, 5, 1),
|
||||
EndDate = new DateOnly(2026, 5, 1),
|
||||
Days = PrimeDays.All,
|
||||
TimeOfDay = TimeSpan.Zero,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
|
||||
@@ -79,4 +79,45 @@ public sealed class StreamAnalyzerTests
|
||||
Assert.Null(result.ResultMarkdown);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,13 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State);
|
||||
NullLogger<TaskRunner>.Instance, state);
|
||||
_waker = new QueueWaker();
|
||||
var picker = new QueuePicker(dbFactory);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,13 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
|
||||
NullLogger<TaskRunner>.Instance, TaskStateServiceBuilder.Build(dbFactory).State);
|
||||
NullLogger<TaskRunner>.Instance, state);
|
||||
_waker = new QueueWaker();
|
||||
var picker = new QueuePicker(dbFactory);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -112,6 +113,69 @@ public sealed class QueueServiceTests : IDisposable
|
||||
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]
|
||||
public async Task Schedule_Filter_Skips_Future_Tasks()
|
||||
{
|
||||
@@ -254,7 +318,8 @@ public sealed class QueueServiceTests : IDisposable
|
||||
|
||||
var finalTask = await _taskRepo.GetByIdAsync(task.Id);
|
||||
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]
|
||||
|
||||
206
tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs
Normal file
206
tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ public class TaskRowViewModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(TaskStatus.Running, "running")]
|
||||
[InlineData(TaskStatus.WaitingForReview, "review")]
|
||||
[InlineData(TaskStatus.Failed, "error")]
|
||||
[InlineData(TaskStatus.Done, "review")]
|
||||
[InlineData(TaskStatus.Done, "done")]
|
||||
[InlineData(TaskStatus.Queued, "queued")]
|
||||
[InlineData(TaskStatus.Idle, "idle")]
|
||||
public void StatusChipClass_Maps_Correctly(TaskStatus s, string expected)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user