feat(worker): add post-merge verification gate for list merges
Per-list optional VerifyCommand (list_config.verify_command) runs via VerifyCommandRunner in the list's working dir right after a successful merge/continue-merge, before the task is allowed to reach Done. A non-zero exit or timeout leaves the merge in place but keeps the task out of Done and reports StatusVerifyFailed with an output excerpt through MergeResultDto/review_task; no command configured behaves exactly as before. Merges against the same repo are now serialized per working dir so a running verify can't be interrupted by a second merge landing mid-build. Adds the field to the List Settings modal (en/de localized) and covers success/failure/timeout in TaskMergeServiceTests + VerifyCommandRunnerTests.
This commit is contained in:
@@ -78,6 +78,16 @@ Offene Entscheidungen dazu:
|
|||||||
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
|
||||||
specifically so `list_worktrees`/the Worktrees overview never see it.
|
specifically so `list_worktrees`/the Worktrees overview never see it.
|
||||||
|
|
||||||
|
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
|
||||||
|
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
|
||||||
|
timeout paths via a fake runner), but **not visually verified**: open a list's Settings modal,
|
||||||
|
confirm the new "VERIFICATION" section renders below Agent with a settable/clearable
|
||||||
|
`VerifyCommand` field; approve a task on a list with a failing command configured and confirm
|
||||||
|
the footer/error surfacing (`ShowErrorAsync`) actually shows the verify failure message instead
|
||||||
|
of silently looking like nothing happened. Also no real-build smoke test (a real `dotnet build`/
|
||||||
|
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
|
||||||
|
`ping` for timeout) were exercised.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|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 / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). 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|WaitingForChildren|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 / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
|
||||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||||
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
|
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
|
||||||
- **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.
|
- **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.
|
||||||
@@ -45,7 +45,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
|||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
|||||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||||
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
||||||
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
||||||
|
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,832 @@
|
|||||||
|
// <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("20260805090016_AddVerifyCommand")]
|
||||||
|
partial class AddVerifyCommand
|
||||||
|
{
|
||||||
|
/// <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<int>("DailyPrepMaxTasks")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(5)
|
||||||
|
.HasColumnName("daily_prep_max_tasks");
|
||||||
|
|
||||||
|
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>("ModelPresets")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
|
b.Property<string>("RepoImportFolders")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
|
b.Property<string>("ReportExcludedPaths")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("report_excluded_paths");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<int>("StandupWeekday")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(3)
|
||||||
|
.HasColumnName("standup_weekday");
|
||||||
|
|
||||||
|
b.Property<int>("UsageGateFiveHourPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("usage_gate_five_hour_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageGateSevenDayPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(90)
|
||||||
|
.HasColumnName("usage_gate_seven_day_pct");
|
||||||
|
|
||||||
|
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,
|
||||||
|
DailyPrepMaxTasks = 5,
|
||||||
|
DefaultClaudeInstructions = "",
|
||||||
|
DefaultMaxTurns = 100,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
WorktreeAutoCleanupDays = 7,
|
||||||
|
WorktreeAutoCleanupEnabled = false,
|
||||||
|
WorktreeStrategy = "sibling"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("note_date");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.ToTable("daily_notes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.Property<string>("VerifyCommand")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("verify_command");
|
||||||
|
|
||||||
|
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<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
|
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.SessionSkillEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AddedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("added_at");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("PinnedRef")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pinned_ref");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("source_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subpath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subpath");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("session_skills", (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.TaskAttachmentEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("ByteSize")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("byte_size");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("file_name");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_task_attachments_task_id");
|
||||||
|
|
||||||
|
b.ToTable("task_attachments", (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<string>("HandlerBaseCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("handler_base_commit");
|
||||||
|
|
||||||
|
b.Property<string>("HandlerHeadCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("handler_head_commit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsManual")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_manual");
|
||||||
|
|
||||||
|
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<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
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<int>("RoadblockCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("roadblock_count");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ScheduledFor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<string>("SessionSkills")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_skills");
|
||||||
|
|
||||||
|
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>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
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.WeekReportEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("EndDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("end_date");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("generated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Markdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("markdown");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("StartDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("start_date");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StartDate", "EndDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("week_reports", (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.TaskAttachmentEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany()
|
||||||
|
.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 AddVerifyCommand : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "verify_command",
|
||||||
|
table: "list_config",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "verify_command",
|
||||||
|
table: "list_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("system_prompt");
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.Property<string>("VerifyCommand")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("verify_command");
|
||||||
|
|
||||||
b.HasKey("ListId");
|
b.HasKey("ListId");
|
||||||
|
|
||||||
b.ToTable("list_config", (string)null);
|
b.ToTable("list_config", (string)null);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public sealed class ListConfigEntity
|
|||||||
public string? AgentPath { get; set; }
|
public string? AgentPath { get; set; }
|
||||||
public int? MaxTurns { get; set; }
|
public int? MaxTurns { get; set; }
|
||||||
public string? SessionSkills { get; set; }
|
public string? SessionSkills { get; set; }
|
||||||
|
public string? VerifyCommand { get; set; }
|
||||||
|
|
||||||
// Navigation property
|
// Navigation property
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ public sealed class ListRepository
|
|||||||
existing.AgentPath = config.AgentPath;
|
existing.AgentPath = config.AgentPath;
|
||||||
existing.MaxTurns = config.MaxTurns;
|
existing.MaxTurns = config.MaxTurns;
|
||||||
existing.SessionSkills = config.SessionSkills;
|
existing.SessionSkills = config.SessionSkills;
|
||||||
|
existing.VerifyCommand = config.VerifyCommand;
|
||||||
}
|
}
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,7 +326,11 @@
|
|||||||
"manualList": "Manuelle Liste (Erinnerungen)",
|
"manualList": "Manuelle Liste (Erinnerungen)",
|
||||||
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
|
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||||
|
"sectionVerify": "VERIFIKATION",
|
||||||
|
"verifyCommand": "Verifikationskommando",
|
||||||
|
"verifyCommandPlaceholder": "(keines — Merge verhält sich wie bisher)",
|
||||||
|
"verifyCommandHint": "Läuft im Arbeitsverzeichnis nach einem Merge, bevor die Aufgabe auf 'Erledigt' gesetzt wird. Ein Exit-Code ungleich 0 (oder Timeout) lässt den Merge bestehen, verhindert aber 'Erledigt' und meldet den Fehler."
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"title": "WORKTREE MERGEN",
|
"title": "WORKTREE MERGEN",
|
||||||
@@ -550,6 +554,7 @@
|
|||||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||||
"listSettings": { "untitled": "Unbenannt" },
|
"listSettings": { "untitled": "Unbenannt" },
|
||||||
|
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
|
||||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,7 +326,11 @@
|
|||||||
"manualList": "Manual list (reminders)",
|
"manualList": "Manual list (reminders)",
|
||||||
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
|
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Reset agent settings"
|
"resetAgentSettings": "Reset agent settings",
|
||||||
|
"sectionVerify": "VERIFICATION",
|
||||||
|
"verifyCommand": "Verify command",
|
||||||
|
"verifyCommandPlaceholder": "(none — merge behaves as today)",
|
||||||
|
"verifyCommandHint": "Runs in the working directory after a merge lands, before the task is marked Done. A non-zero exit (or timeout) leaves the merge in place but keeps the task out of Done and reports the failure."
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"title": "MERGE WORKTREE",
|
"title": "MERGE WORKTREE",
|
||||||
@@ -550,6 +554,7 @@
|
|||||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||||
"listSettings": { "untitled": "Untitled" },
|
"listSettings": { "untitled": "Untitled" },
|
||||||
|
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
|
||||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
|||||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
|
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
|
||||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets` → `ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, `VerifyCommand` (optional post-merge verify gate, own field/section — not part of `AgentConfigEditorViewModel`), delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync(verifyCommand)`, since both fields land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which first calls `IWorkerClient.CreateMergeHelperTaskAsync` to create one new ClaudeDo task per run in that list — `Idle`/`IsManual=true` (never queued), title/description localized (`missionControl.mergeHelperTaskTitle`/`mergeHelperTaskDescriptionHeader`), `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD — then opens a **task-based** ConPTY tile for it (deduped by `TaskId` like `OpenConPtySessionAsync`, not `CreateAdHoc`) running the five-phase handler prompt. The handler still merges the tasks it handles itself; the host task never gets a worktree of its own, so "Submit for review" stamps `HandlerHeadCommit` instead of committing a worktree, and the detail pane's `MergeSectionViewModel` falls back to `HandlerBaseCommit`/`HandlerHeadCommit` over the list's working dir for its diff — see `TaskEntity` in `ClaudeDo.Data/CLAUDE.md`), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
||||||
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||||
|
|
||||||
|
|||||||
@@ -630,9 +630,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
|||||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
||||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
||||||
public sealed record SeedResultDto(int Copied, int Skipped);
|
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
public sealed record WorktreeOverviewDto(
|
public sealed record WorktreeOverviewDto(
|
||||||
|
|||||||
@@ -184,7 +184,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async System.Threading.Tasks.Task SaveAsync()
|
// verifyCommand is a List-only field owned by ListSettingsModalViewModel (not this editor,
|
||||||
|
// which is also reused for Task scope); the caller passes it through so the single
|
||||||
|
// UpdateListConfig call carries the full desired row instead of clobbering it.
|
||||||
|
public async System.Threading.Tasks.Task SaveAsync(string? verifyCommand = null)
|
||||||
{
|
{
|
||||||
if (TargetId is null) return;
|
if (TargetId is null) return;
|
||||||
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
||||||
@@ -196,7 +199,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
|||||||
if (_scope == AgentConfigScope.Task)
|
if (_scope == AgentConfigScope.Task)
|
||||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
|
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
|
||||||
else
|
else
|
||||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
|
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand));
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<string>? SelectedSessionSkillNames()
|
private List<string>? SelectedSessionSkillNames()
|
||||||
|
|||||||
@@ -1073,6 +1073,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
||||||
if (!hasChildren && result?.Status == "conflict")
|
if (!hasChildren && result?.Status == "conflict")
|
||||||
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
||||||
|
// The merge itself already landed; the verify command failed, so the task stayed
|
||||||
|
// out of Done. Surface that instead of silently looking like nothing happened.
|
||||||
|
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
||||||
[ObservableProperty] private bool _isManual;
|
[ObservableProperty] private bool _isManual;
|
||||||
|
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
|
||||||
|
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
|
||||||
|
[ObservableProperty] private string _verifyCommand = "";
|
||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
@@ -61,6 +64,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
await Agent.LoadForListAsync(listId, ct);
|
await Agent.LoadForListAsync(listId, ct);
|
||||||
|
var cfg = await _worker.GetListConfigAsync(listId);
|
||||||
|
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -73,7 +78,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
DefaultCommitType,
|
DefaultCommitType,
|
||||||
IsManual));
|
IsManual));
|
||||||
|
|
||||||
await Agent.SaveAsync();
|
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
|
||||||
|
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- VERIFICATION -->
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr modals.listSettings.sectionVerify}"/>
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.verifyCommand}"/>
|
||||||
|
<TextBox Text="{Binding VerifyCommand, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
PlaceholderText="{loc:Tr modals.listSettings.verifyCommandPlaceholder}"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.listSettings.verifyCommandHint}"
|
||||||
|
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
|||||||
Worker/
|
Worker/
|
||||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
|
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
|
||||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
|
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, VerifyCommandRunner (IVerifyCommandRunner — spawns a list's optional post-merge verify command via `cmd.exe /c`), ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
|
||||||
Worktrees/ — WorktreeMaintenanceService
|
Worktrees/ — WorktreeMaintenanceService
|
||||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||||
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||||
@@ -101,7 +101,25 @@ that has children, drives `PlanningMergeOrchestrator` (merges the parent worktre
|
|||||||
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
|
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
|
||||||
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
|
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
|
||||||
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
|
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
|
||||||
approve is the single review+merge action. Review transitions live in `TaskStateService`
|
approve is the single review+merge action.
|
||||||
|
|
||||||
|
**Post-merge verify gate.** A list can set `ListConfigEntity.VerifyCommand` (List Settings
|
||||||
|
modal → Verification). Null/blank (the default) = no gate, behavior is bit-identical to
|
||||||
|
before this existed. When set, `TaskMergeService` runs it via `VerifyCommandRunner`
|
||||||
|
(`cmd.exe /c <command>`, 10-minute fixed timeout, output tail-captured) in `list.WorkingDir`
|
||||||
|
right after a successful `MergeNoFfAsync`/`ContinueMergeAsync` and worktree cleanup, but
|
||||||
|
*before* the task is allowed to reach `Done`. Exit 0 → unchanged flow (worktree marked
|
||||||
|
`Merged`, task `Done` if it was `WaitingForReview`). Non-zero exit or a timeout → the git
|
||||||
|
merge is deliberately left in place (no auto-revert — that's a separate, unbuilt feature),
|
||||||
|
the worktree is still marked `Merged` (it's already gone from disk when `removeWorktree`
|
||||||
|
was requested), but the task stays out of `Done` and `MergeResult.Status` comes back
|
||||||
|
`TaskMergeService.StatusVerifyFailed` (`"verify_failed"`) with an output excerpt in
|
||||||
|
`ErrorMessage` — this flows through `MergeResultDto` (hub) and `ReviewTaskResult`
|
||||||
|
(`review_task` MCP tool) unchanged, since both already treat any non-`blocked`/`conflict`
|
||||||
|
status generically. A process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
|
||||||
|
`list.WorkingDir` serializes `MergeAsync`/`ContinueMergeAsync` (git ops + verify) per repo,
|
||||||
|
so a verify run can't be interrupted by a second merge landing in the same working dir
|
||||||
|
mid-build. Review transitions live in `TaskStateService`
|
||||||
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
|
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
|
||||||
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
|||||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
|
||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
||||||
public record SeedResultDto(int Copied, int Skipped);
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
public record OnlineInboxStateDto(
|
public record OnlineInboxStateDto(
|
||||||
@@ -521,8 +521,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
||||||
var agentPath = dto.AgentPath.NullIfBlank();
|
var agentPath = dto.AgentPath.NullIfBlank();
|
||||||
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
||||||
|
var verifyCommand = dto.VerifyCommand.NullIfBlank();
|
||||||
|
|
||||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
|
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null)
|
||||||
{
|
{
|
||||||
await repo.DeleteConfigAsync(dto.ListId);
|
await repo.DeleteConfigAsync(dto.ListId);
|
||||||
}
|
}
|
||||||
@@ -536,6 +537,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
AgentPath = agentPath,
|
AgentPath = agentPath,
|
||||||
MaxTurns = dto.MaxTurns,
|
MaxTurns = dto.MaxTurns,
|
||||||
SessionSkills = sessionSkills,
|
SessionSkills = sessionSkills,
|
||||||
|
VerifyCommand = verifyCommand,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,7 +550,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var repo = new ListRepository(ctx);
|
var repo = new ListRepository(ctx);
|
||||||
var config = await repo.GetConfigAsync(listId);
|
var config = await repo.GetConfigAsync(listId);
|
||||||
if (config is null) return null;
|
if (config is null) return null;
|
||||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
|
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetTaskStatus(string taskId, string status)
|
public async Task SetTaskStatus(string taskId, string status)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
public sealed record VerifyCommandResult(int ExitCode, bool TimedOut, string Output);
|
||||||
|
|
||||||
|
public interface IVerifyCommandRunner
|
||||||
|
{
|
||||||
|
Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
@@ -38,15 +39,31 @@ public sealed class TaskMergeService
|
|||||||
public const string StatusConflict = "conflict";
|
public const string StatusConflict = "conflict";
|
||||||
public const string StatusBlocked = "blocked";
|
public const string StatusBlocked = "blocked";
|
||||||
public const string StatusAborted = "aborted";
|
public const string StatusAborted = "aborted";
|
||||||
|
public const string StatusVerifyFailed = "verify_failed";
|
||||||
|
|
||||||
public const string PreviewClean = "clean";
|
public const string PreviewClean = "clean";
|
||||||
public const string PreviewConflict = "conflict";
|
public const string PreviewConflict = "conflict";
|
||||||
public const string PreviewUnavailable = "unavailable";
|
public const string PreviewUnavailable = "unavailable";
|
||||||
|
|
||||||
|
// The verify command is a trusted, list-owner-configured build/test invocation (not
|
||||||
|
// per-request user input), so a generous fixed timeout is enough — no need for a
|
||||||
|
// per-list configurable value on top of what the spec calls for.
|
||||||
|
private static readonly TimeSpan VerifyTimeout = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
// Serializes merge (+ verify) against the same repo working dir: a verify command running
|
||||||
|
// in list.WorkingDir must not see a second merge land mid-build. Keyed by working dir since
|
||||||
|
// TaskMergeService is a process-wide singleton and merges across different lists are independent.
|
||||||
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> MergeGates =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static SemaphoreSlim GetMergeGate(string workingDir) =>
|
||||||
|
MergeGates.GetOrAdd(workingDir, static _ => new SemaphoreSlim(1, 1));
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
|
private readonly IVerifyCommandRunner _verify;
|
||||||
private readonly ILogger<TaskMergeService> _logger;
|
private readonly ILogger<TaskMergeService> _logger;
|
||||||
|
|
||||||
public TaskMergeService(
|
public TaskMergeService(
|
||||||
@@ -54,25 +71,67 @@ public sealed class TaskMergeService
|
|||||||
GitService git,
|
GitService git,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
|
IVerifyCommandRunner verify,
|
||||||
ILogger<TaskMergeService> logger)
|
ILogger<TaskMergeService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_git = git;
|
_git = git;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_state = state;
|
_state = state;
|
||||||
|
_verify = verify;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree)> LoadMergeContextAsync(
|
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync(
|
||||||
string taskId, CancellationToken ct)
|
string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
||||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
|
var listRepo = new ListRepository(ctx);
|
||||||
|
var list = await listRepo.GetByIdAsync(task.ListId, ct)
|
||||||
?? throw new InvalidOperationException("List not found.");
|
?? throw new InvalidOperationException("List not found.");
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
||||||
return (task, list, wt);
|
var config = await listRepo.GetConfigAsync(task.ListId, ct);
|
||||||
|
return (task, list, wt, config?.VerifyCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the list's configured verify command (if any) in <paramref name="workingDir"/> after
|
||||||
|
/// a successful merge. Returns null when there is nothing to gate on (identical to today's
|
||||||
|
/// behavior); otherwise returns the terminal <see cref="MergeResult"/> to report instead of
|
||||||
|
/// merged (the merge itself is left in place either way — see the design notes in Worker's
|
||||||
|
/// CLAUDE.md — only the Done transition is withheld).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<MergeResult?> RunVerifyGateAsync(
|
||||||
|
string? verifyCommand, string workingDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(verifyCommand)) return null;
|
||||||
|
|
||||||
|
VerifyCommandResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "verify command failed to start: {Command}", verifyCommand);
|
||||||
|
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(),
|
||||||
|
$"verify command failed to start: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.TimedOut && result.ExitCode == 0) return null;
|
||||||
|
|
||||||
|
var reason = result.TimedOut
|
||||||
|
? $"verify command timed out after {VerifyTimeout.TotalMinutes:0} min: {verifyCommand}"
|
||||||
|
: $"verify command failed (exit {result.ExitCode}): {verifyCommand}";
|
||||||
|
return new MergeResult(StatusVerifyFailed, Array.Empty<string>(), $"{reason}\n{TailOutput(result.Output)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TailOutput(string output, int maxChars = 4000)
|
||||||
|
{
|
||||||
|
var trimmed = output.Trim();
|
||||||
|
return trimmed.Length <= maxChars ? trimmed : trimmed[^maxChars..];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct)
|
private async Task MarkWorktreeMergedAsync(string taskId, CancellationToken ct)
|
||||||
@@ -101,7 +160,7 @@ public sealed class TaskMergeService
|
|||||||
bool leaveConflictsInTree,
|
bool leaveConflictsInTree,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (task.Status == TaskStatus.Running)
|
if (task.Status == TaskStatus.Running)
|
||||||
return Blocked("task is running");
|
return Blocked("task is running");
|
||||||
@@ -111,6 +170,11 @@ public sealed class TaskMergeService
|
|||||||
return Blocked($"worktree state is {wt.State}");
|
return Blocked($"worktree state is {wt.State}");
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
return Blocked("list has no working directory");
|
return Blocked("list has no working directory");
|
||||||
|
|
||||||
|
var gate = GetMergeGate(list.WorkingDir);
|
||||||
|
await gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
if (!await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||||
return Blocked("working directory is not a git repository");
|
return Blocked("working directory is not a git repository");
|
||||||
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||||
@@ -176,6 +240,15 @@ public sealed class TaskMergeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MarkWorktreeMergedAsync(taskId, ct);
|
await MarkWorktreeMergedAsync(taskId, ct);
|
||||||
|
|
||||||
|
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
|
||||||
|
if (verifyFailure is not null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
|
||||||
|
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
|
return verifyFailure;
|
||||||
|
}
|
||||||
|
|
||||||
await ApproveIfWaitingForReviewAsync(task, ct);
|
await ApproveIfWaitingForReviewAsync(task, ct);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -185,6 +258,8 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
|
||||||
}
|
}
|
||||||
|
finally { gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
public Task<MergeResult> MergeAsync(
|
public Task<MergeResult> MergeAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
@@ -196,11 +271,16 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (wt is null) return Blocked("task has no worktree");
|
if (wt is null) return Blocked("task has no worktree");
|
||||||
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
|
if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory");
|
||||||
|
|
||||||
|
var gate = GetMergeGate(list.WorkingDir);
|
||||||
|
await gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||||
return Blocked("repo is not mid-merge");
|
return Blocked("repo is not mid-merge");
|
||||||
|
|
||||||
@@ -234,15 +314,26 @@ public sealed class TaskMergeService
|
|||||||
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
|
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
|
||||||
|
|
||||||
await MarkWorktreeMergedAsync(taskId, ct);
|
await MarkWorktreeMergedAsync(taskId, ct);
|
||||||
|
|
||||||
|
var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct);
|
||||||
|
if (verifyFailure is not null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
|
||||||
|
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
|
||||||
|
return verifyFailure;
|
||||||
|
}
|
||||||
|
|
||||||
await ApproveIfWaitingForReviewAsync(task, ct);
|
await ApproveIfWaitingForReviewAsync(task, ct);
|
||||||
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
|
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
|
||||||
|
|
||||||
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
|
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
|
||||||
}
|
}
|
||||||
|
finally { gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
|
public async Task<MergeResult> AbortMergeAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (wt is null) return Blocked("task has no worktree");
|
if (wt is null) return Blocked("task has no worktree");
|
||||||
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
||||||
@@ -263,7 +354,7 @@ public sealed class TaskMergeService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
|
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
throw new InvalidOperationException("list has no working directory");
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
@@ -298,7 +389,7 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
throw new InvalidOperationException("list has no working directory");
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
@@ -309,7 +400,7 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
return new MergeTargets("", Array.Empty<string>());
|
return new MergeTargets("", Array.Empty<string>());
|
||||||
@@ -321,7 +412,7 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (wt is null || wt.State != WorktreeState.Active)
|
if (wt is null || wt.State != WorktreeState.Active)
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||||
@@ -348,7 +439,7 @@ public sealed class TaskMergeService
|
|||||||
public async Task<MergeResult> ApproveAndMergeAsync(
|
public async Task<MergeResult> ApproveAndMergeAsync(
|
||||||
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
|
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (task, list, wt, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (task.Status != TaskStatus.WaitingForReview)
|
if (task.Status != TaskStatus.WaitingForReview)
|
||||||
return Blocked("task is not waiting for review");
|
return Blocked("task is not waiting for review");
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a list's configured post-merge verification command (e.g. a build/test invocation)
|
||||||
|
/// via cmd.exe, mirroring GitService's ProcessStartInfo discipline (no shell string
|
||||||
|
/// concatenation beyond the single /c argument cmd.exe itself requires to parse a command line).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VerifyCommandRunner : IVerifyCommandRunner
|
||||||
|
{
|
||||||
|
// Safety cap so a runaway/chatty command can't exhaust memory; only the tail matters anyway.
|
||||||
|
private const int MaxOutputChars = 512_000;
|
||||||
|
|
||||||
|
public async Task<VerifyCommandResult> RunAsync(
|
||||||
|
string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "cmd.exe",
|
||||||
|
WorkingDirectory = workingDir,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
StandardErrorEncoding = Encoding.UTF8,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("/c");
|
||||||
|
psi.ArgumentList.Add(command);
|
||||||
|
|
||||||
|
using var process = new Process { StartInfo = psi };
|
||||||
|
var output = new StringBuilder();
|
||||||
|
var sync = new object();
|
||||||
|
|
||||||
|
void Append(string? line)
|
||||||
|
{
|
||||||
|
if (line is null) return;
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
if (output.Length >= MaxOutputChars) return;
|
||||||
|
output.AppendLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.OutputDataReceived += (_, e) => Append(e.Data);
|
||||||
|
process.ErrorDataReceived += (_, e) => Append(e.Data);
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(timeout);
|
||||||
|
|
||||||
|
var timedOut = false;
|
||||||
|
|
||||||
|
// On cancellation (timeout or caller): kill the tree. Killing unblocks WaitForExitAsync
|
||||||
|
// below and lets the async output readers drain/complete naturally.
|
||||||
|
await using var ctr = cts.Token.Register(() =>
|
||||||
|
{
|
||||||
|
timedOut = !ct.IsCancellationRequested;
|
||||||
|
try { process.Kill(entireProcessTree: true); }
|
||||||
|
catch { /* already exited */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
string finalOutput;
|
||||||
|
lock (sync) finalOutput = output.ToString();
|
||||||
|
|
||||||
|
return new VerifyCommandResult(process.ExitCode, timedOut, finalOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ builder.Services.AddSingleton<IInteractiveLaunchSpecService, InteractiveLaunchSp
|
|||||||
builder.Services.AddSingleton<TaskRunner>();
|
builder.Services.AddSingleton<TaskRunner>();
|
||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
builder.Services.AddSingleton<TaskResetService>();
|
builder.Services.AddSingleton<TaskResetService>();
|
||||||
|
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
|
||||||
builder.Services.AddSingleton<TaskMergeService>();
|
builder.Services.AddSingleton<TaskMergeService>();
|
||||||
builder.Services.AddSingleton<PlanningAggregator>();
|
builder.Services.AddSingleton<PlanningAggregator>();
|
||||||
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public sealed class AddSubtaskToolTests : IDisposable
|
|||||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
||||||
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
|
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state, runCancels);
|
||||||
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
var planningMerge = new PlanningMergeOrchestrator(
|
var planningMerge = new PlanningMergeOrchestrator(
|
||||||
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
|||||||
var factory = _db.CreateFactory();
|
var factory = _db.CreateFactory();
|
||||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
var state = TaskStateServiceBuilder.Build(factory).State;
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
||||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
var planningMerge = new PlanningMergeOrchestrator(
|
var planningMerge = new PlanningMergeOrchestrator(
|
||||||
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var factory = _db.CreateFactory();
|
var factory = _db.CreateFactory();
|
||||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||||
var state = TaskStateServiceBuilder.Build(factory).State;
|
var state = TaskStateServiceBuilder.Build(factory).State;
|
||||||
var merge = new TaskMergeService(factory, git, _broadcaster, state, NullLogger<TaskMergeService>.Instance);
|
var merge = new TaskMergeService(factory, git, _broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
|
||||||
var planningMerge = new PlanningMergeOrchestrator(
|
var planningMerge = new PlanningMergeOrchestrator(
|
||||||
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
factory, merge, aggregator, _broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
var merge = new TaskMergeService(
|
var merge = new TaskMergeService(
|
||||||
factory, git, broadcaster,
|
factory, git, broadcaster,
|
||||||
built.State,
|
built.State,
|
||||||
|
new VerifyCommandRunner(),
|
||||||
NullLogger<TaskMergeService>.Instance);
|
NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(
|
var aggregator = new PlanningAggregator(
|
||||||
factory, git,
|
factory, git,
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ public sealed class TreeMergeTests : IDisposable
|
|||||||
var merge = new TaskMergeService(
|
var merge = new TaskMergeService(
|
||||||
factory, git, broadcaster,
|
factory, git, broadcaster,
|
||||||
built.State,
|
built.State,
|
||||||
|
new VerifyCommandRunner(),
|
||||||
NullLogger<TaskMergeService>.Instance);
|
NullLogger<TaskMergeService>.Instance);
|
||||||
var aggregator = new PlanningAggregator(
|
var aggregator = new PlanningAggregator(
|
||||||
factory, git,
|
factory, git,
|
||||||
|
|||||||
@@ -96,6 +96,38 @@ public sealed class ListRepositoryConfigTests : IDisposable
|
|||||||
Assert.Null(fetched.SessionSkills);
|
Assert.Null(fetched.SessionSkills);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetConfig_Persists_VerifyCommand_On_Insert()
|
||||||
|
{
|
||||||
|
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
|
||||||
|
|
||||||
|
var fetched = await _repo.GetConfigAsync(_listId);
|
||||||
|
Assert.NotNull(fetched);
|
||||||
|
Assert.Equal("dotnet test", fetched.VerifyCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetConfig_Persists_VerifyCommand_On_Update()
|
||||||
|
{
|
||||||
|
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet build" });
|
||||||
|
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
|
||||||
|
|
||||||
|
var fetched = await _repo.GetConfigAsync(_listId);
|
||||||
|
Assert.NotNull(fetched);
|
||||||
|
Assert.Equal("dotnet test", fetched.VerifyCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetConfig_Null_VerifyCommand_Clears_On_Update()
|
||||||
|
{
|
||||||
|
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = "dotnet test" });
|
||||||
|
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, VerifyCommand = null });
|
||||||
|
|
||||||
|
var fetched = await _repo.GetConfigAsync(_listId);
|
||||||
|
Assert.NotNull(fetched);
|
||||||
|
Assert.Null(fetched.VerifyCommand);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_ctx.Dispose();
|
_ctx.Dispose();
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
foreach (var r in _repos) try { r.Dispose(); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
|
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(
|
||||||
|
DbFixture db, IVerifyCommandRunner? verify = null)
|
||||||
{
|
{
|
||||||
var fakeHub = new MergeRecordingHubContext();
|
var fakeHub = new MergeRecordingHubContext();
|
||||||
var broadcaster = new HubBroadcaster(fakeHub);
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
@@ -40,10 +41,17 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
new GitService(),
|
new GitService(),
|
||||||
broadcaster,
|
broadcaster,
|
||||||
state,
|
state,
|
||||||
|
verify ?? new VerifyCommandRunner(),
|
||||||
NullLogger<TaskMergeService>.Instance);
|
NullLogger<TaskMergeService>.Instance);
|
||||||
return (svc, fakeHub.Proxy);
|
return (svc, fakeHub.Proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task SeedVerifyCommand(DbFixture db, string listId, string command)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, VerifyCommand = command });
|
||||||
|
}
|
||||||
|
|
||||||
private static WorktreeManager BuildWorktreeManager(DbFixture db)
|
private static WorktreeManager BuildWorktreeManager(DbFixture db)
|
||||||
{
|
{
|
||||||
return new WorktreeManager(
|
return new WorktreeManager(
|
||||||
@@ -704,6 +712,125 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_NoVerifyCommandConfigured_NeverInvokesRunnerAndMarksDone()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "should never run") };
|
||||||
|
var (svc, _) = BuildService(db, fakeVerify);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Null(fakeVerify.CapturedCommand);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_VerifyCommandSucceeds_RunsInListWorkingDirAndMarksDone()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "all green") };
|
||||||
|
var (svc, _) = BuildService(db, fakeVerify);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Equal("dotnet test", fakeVerify.CapturedCommand);
|
||||||
|
Assert.Equal(repo.RepoDir, fakeVerify.CapturedWorkingDir);
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_VerifyCommandFails_KeepsMergeButNotDone()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(1, false, "build broke: CS0103") };
|
||||||
|
var (svc, _) = BuildService(db, fakeVerify);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
||||||
|
Assert.Contains("build broke: CS0103", result.ErrorMessage);
|
||||||
|
|
||||||
|
// The git merge itself is left in place — main already has the merged content.
|
||||||
|
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||||
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveAndMergeAsync_VerifyCommandTimesOut_ReturnsVerifyFailedWithTimeoutMessage()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
var repo = NewRepo();
|
||||||
|
var db = NewDb();
|
||||||
|
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||||
|
await SeedVerifyCommand(db, list.Id, "dotnet test");
|
||||||
|
|
||||||
|
var wtMgr = BuildWorktreeManager(db);
|
||||||
|
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||||
|
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||||
|
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(-1, true, "") };
|
||||||
|
var (svc, _) = BuildService(db, fakeVerify);
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||||
|
|
||||||
|
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status);
|
||||||
|
Assert.Contains("timed out", result.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
||||||
{
|
{
|
||||||
@@ -776,6 +903,20 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
|
|
||||||
#region Test doubles
|
#region Test doubles
|
||||||
|
|
||||||
|
internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner
|
||||||
|
{
|
||||||
|
public VerifyCommandResult Result { get; set; } = new(0, false, "");
|
||||||
|
public string? CapturedWorkingDir { get; private set; }
|
||||||
|
public string? CapturedCommand { get; private set; }
|
||||||
|
|
||||||
|
public Task<VerifyCommandResult> RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CapturedWorkingDir = workingDir;
|
||||||
|
CapturedCommand = command;
|
||||||
|
return Task.FromResult(Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed record MergeHubCall(string Method, object?[] Args);
|
internal sealed record MergeHubCall(string Method, object?[] Args);
|
||||||
|
|
||||||
internal sealed class MergeRecordingClientProxy : IClientProxy
|
internal sealed class MergeRecordingClientProxy : IClientProxy
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Services;
|
||||||
|
|
||||||
|
public class VerifyCommandRunnerTests
|
||||||
|
{
|
||||||
|
private readonly VerifyCommandRunner _runner = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ExitsZero_ReportsSuccessNotTimedOut()
|
||||||
|
{
|
||||||
|
var result = await _runner.RunAsync(
|
||||||
|
Path.GetTempPath(), "exit 0", TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(0, result.ExitCode);
|
||||||
|
Assert.False(result.TimedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_NonZeroExit_ReportsExitCode()
|
||||||
|
{
|
||||||
|
var result = await _runner.RunAsync(
|
||||||
|
Path.GetTempPath(), "exit 7", TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(7, result.ExitCode);
|
||||||
|
Assert.False(result.TimedOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_CapturesStdoutAndStderr()
|
||||||
|
{
|
||||||
|
var result = await _runner.RunAsync(
|
||||||
|
Path.GetTempPath(), "echo hello-out & echo hello-err 1>&2", TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains("hello-out", result.Output);
|
||||||
|
Assert.Contains("hello-err", result.Output);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_RunsInSpecifiedWorkingDirectory()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), $"verify_wd_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _runner.RunAsync(dir, "cd", TimeSpan.FromSeconds(30), CancellationToken.None);
|
||||||
|
Assert.Contains(new DirectoryInfo(dir).Name, result.Output);
|
||||||
|
}
|
||||||
|
finally { Directory.Delete(dir, recursive: true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_ExceedsTimeout_KillsProcessAndReportsTimedOut()
|
||||||
|
{
|
||||||
|
var result = await _runner.RunAsync(
|
||||||
|
Path.GetTempPath(), "ping -n 60 127.0.0.1", TimeSpan.FromMilliseconds(300), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.TimedOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user