Compare commits
219
Commits
bf19e84e76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925ba655a9 | ||
|
|
f9a8ed761a | ||
|
|
0e314e44dd | ||
|
|
9c4f0d1872 | ||
|
|
23aab26daf | ||
|
|
1341c5f4f3 | ||
|
|
aeb1a5eb82 | ||
|
|
0de3816fd0 | ||
|
|
53e26f0340 | ||
|
|
9099b22eea | ||
|
|
ce1a26d407 | ||
|
|
555933a93c | ||
|
|
43fbfd3fa2 | ||
|
|
ec9ad1b0e1 | ||
|
|
8a1e2c9698 | ||
|
|
c1ec05a437 | ||
|
|
b8caa27027 | ||
|
|
db32e5307f | ||
|
|
c795827162 | ||
|
|
a9af650407 | ||
|
|
c9afc81f0a | ||
|
|
ea4db9d0bd | ||
|
|
589b9e75f3 | ||
|
|
f6994f32d5 | ||
|
|
9fc8ed391e | ||
|
|
b9827eac01 | ||
|
|
69b07fff26 | ||
|
|
030b3ceb6c | ||
|
|
ec4cbcbaf9 | ||
|
|
5fca459579 | ||
|
|
5a74a67660 | ||
|
|
bea85a9ee5 | ||
|
|
bbd6151ba6 | ||
|
|
a0d5db0db0 | ||
|
|
d3abd4b88b | ||
|
|
8098b7f4ec | ||
|
|
3e3f44bfb9 | ||
|
|
5353e5c39c | ||
|
|
ca65849280 | ||
|
|
a614feb96d | ||
|
|
eca16a3506 | ||
|
|
260cb5a89c | ||
|
|
72d46af6e5 | ||
|
|
ba1a745e09 | ||
|
|
cad0582b37 | ||
|
|
fc9df7f9ac | ||
|
|
e55c84cda4 | ||
|
|
d327a1007e | ||
|
|
1861832b99 | ||
|
|
c35fabe0a2 | ||
|
|
ad6af68895 | ||
|
|
fb9fc6e9b5 | ||
|
|
1a0cb97325 | ||
|
|
340f2c9e14 | ||
|
|
4ce5ae7189 | ||
|
|
f6926f08a6 | ||
|
|
b656a241a2 | ||
|
|
1003fdbf46 | ||
|
|
e343baf21a | ||
|
|
39b24c2f74 | ||
|
|
6c4fa7b1a5 | ||
|
|
79b35801ae | ||
|
|
eb66ae7b8b | ||
|
|
3c7871afe2 | ||
|
|
b81d6e0b4f | ||
|
|
9977fe4c3e | ||
|
|
42279e0806 | ||
|
|
18babe3dee | ||
|
|
e8e2fcec3f | ||
|
|
0e12be480b | ||
|
|
38af549a80 | ||
|
|
a1aeb21481 | ||
|
|
2a3133efad | ||
|
|
9660a29da4 | ||
|
|
106c964410 | ||
|
|
8da1a12389 | ||
|
|
5ab5a6c0a8 | ||
|
|
9e46c96b24 | ||
|
|
31a9e87b56 | ||
|
|
d885b4eb41 | ||
|
|
c8244c67f3 | ||
|
|
ee7003d964 | ||
|
|
f2609d186a | ||
|
|
6927805600 | ||
|
|
1657a70962 | ||
|
|
a311152b25 | ||
|
|
1203323102 | ||
|
|
e555bdb8a4 | ||
|
|
d3b91b5214 | ||
|
|
095d216bda | ||
|
|
d60ecb7f1b | ||
|
|
6a731beb53 | ||
|
|
11103d4d3e | ||
|
|
1acd4c1734 | ||
|
|
d3ba279b54 | ||
|
|
7106bf754f | ||
|
|
f31b1b4fb2 | ||
|
|
d451edd367 | ||
|
|
6d8cbacc9b | ||
|
|
bbfe40242c | ||
|
|
2fe40f3d65 | ||
|
|
ec6c230822 | ||
|
|
82881acc70 | ||
|
|
1b7b18fdfc | ||
|
|
1cad264967 | ||
|
|
514d6111fe | ||
|
|
66f7a8ae99 | ||
|
|
fb12d965fc | ||
|
|
8b75a92794 | ||
|
|
333ce89eef | ||
|
|
a9396c1a99 | ||
|
|
f41a3e9083 | ||
|
|
88207ee2a2 | ||
|
|
85c8c36f2c | ||
|
|
6db3f0f537 | ||
|
|
21c9c3b3b9 | ||
|
|
f614323fa9 | ||
|
|
2fd7f829bd | ||
|
|
d95ae55415 | ||
|
|
474c7201c9 | ||
|
|
19b31450b1 | ||
|
|
df021a6cb8 | ||
|
|
3fc67fb422 | ||
|
|
52b42c0a35 | ||
|
|
31a89bd93f | ||
|
|
9ee6defd69 | ||
|
|
5a5c95f1c8 | ||
|
|
a1c90041c6 | ||
|
|
0bfb1f5657 | ||
|
|
47918869e9 | ||
|
|
2662e7bd98 | ||
|
|
db50e710b9 | ||
|
|
d3155e6868 | ||
|
|
84715665c2 | ||
|
|
09add29994 | ||
|
|
a067a7bfff | ||
|
|
ca6eb62b5c | ||
|
|
6c8ec245b3 | ||
|
|
359bb3a439 | ||
|
|
5eea439129 | ||
|
|
e98cf46097 | ||
|
|
7f10d45898 | ||
|
|
5653daca6e | ||
|
|
659bf3e743 | ||
|
|
f74c7dd70b | ||
|
|
07ee535b7d | ||
|
|
c715c96af9 | ||
|
|
2a8991496f | ||
|
|
afe1b68f46 | ||
|
|
effece3a41 | ||
|
|
5d362b6973 | ||
|
|
1b7f7f38ae | ||
|
|
29c79e8b8c | ||
|
|
6a2a19cc9e | ||
|
|
0568f003d9 | ||
|
|
696abd0a7a | ||
|
|
f717742379 | ||
|
|
87397ecfa9 | ||
|
|
519f64c02e | ||
|
|
b312095fb2 | ||
|
|
2d4288abca | ||
|
|
9a09dd6186 | ||
|
|
389892d277 | ||
|
|
4d997a5f99 | ||
|
|
d3c2e2e7c8 | ||
|
|
37c3b476cf | ||
|
|
ae1f6fc78a | ||
|
|
18a3260683 | ||
|
|
e10634bdc4 | ||
|
|
549aee097b | ||
|
|
af0f318bf0 | ||
|
|
b06bbc213d | ||
|
|
9437121f3a | ||
|
|
6dff72f27c | ||
|
|
15958b992d | ||
|
|
df9e85a28b | ||
|
|
7eeb8f5086 | ||
|
|
b0c23a2bf6 | ||
|
|
4e44251d54 | ||
|
|
2f3f9387c4 | ||
|
|
51371cf678 | ||
|
|
54b179fb0b | ||
|
|
c6d1fff8b1 | ||
|
|
231b063751 | ||
|
|
1f8f3efc72 | ||
|
|
ac586797ce | ||
|
|
fb29e8a871 | ||
|
|
f7fa8292e0 | ||
|
|
66630d5ce2 | ||
|
|
1e383e1c1f | ||
|
|
3acb1cba8f | ||
|
|
cf80fe3cd6 | ||
|
|
a7a3545e2b | ||
|
|
14e0cffa25 | ||
|
|
861ba12f7f | ||
|
|
c1184adc92 | ||
|
|
e299421f76 | ||
|
|
ffc60c4101 | ||
|
|
c792765ed3 | ||
|
|
e2d987e976 | ||
|
|
eeee9591aa | ||
|
|
59827757f0 | ||
|
|
ac099dd1a8 | ||
|
|
ebbee6005d | ||
|
|
cba7d01c31 | ||
|
|
3cd0f0879e | ||
|
|
f62dbb9239 | ||
|
|
6e9a7cea87 | ||
|
|
8acf9f8d7d | ||
|
|
292d17173e | ||
|
|
482d7ec332 | ||
|
|
315bea7cf9 | ||
|
|
c4e4e0976a | ||
|
|
028ac57398 | ||
|
|
a7ff1b3a2f | ||
|
|
d5730a8405 | ||
|
|
17e6cd6118 | ||
|
|
4a7b00ed53 | ||
|
|
f3e6655fc2 |
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"worktree": {
|
||||||
|
"baseRef": "head"
|
||||||
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
|
|||||||
+1330
-1059
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ These sit **between** the CLAUDE.md files and the code:
|
|||||||
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
|
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
|
||||||
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
|
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
|
||||||
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
|
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
|
||||||
|
| [list-virtualization-spike](list-virtualization-spike.md) | Phase 2a gate spike: virtualized `ListBox` + the existing ghost-drag `InputHitTest` model, verified headless |
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ConPTY interactive sessions & launch specs
|
# ConPTY interactive sessions & launch specs
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `8dbdfb3` (2026-08-06).
|
> Last verified against commit `79b3580` (2026-08-11).
|
||||||
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
|
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ Autonomous queue tasks are **not** covered here — they stay on the stream-json
|
|||||||
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
|
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
|
||||||
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
|
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
|
||||||
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
|
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
|
||||||
| List handler | `GetMergeHelperLaunchSpec` | Effort from list config; `--permission-mode auto` (unattended) |
|
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
|
||||||
|
|
||||||
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
||||||
|
|
||||||
@@ -46,6 +46,29 @@ except for a fresh task session with a brief, where `--add-dir <sessionDir>` mus
|
|||||||
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
|
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
|
||||||
models in the TUI.
|
models in the TUI.
|
||||||
|
|
||||||
|
### ⚠️ Gotcha: no directory arg may end in a separator
|
||||||
|
|
||||||
|
`PtyTerminalSession` hands `Args` to `TerminalControl.Args`, which the library flattens into ONE
|
||||||
|
Windows command line with each token quoted. Windows argv rules read `\"` as an *escaped* quote,
|
||||||
|
so a token like `"C:\repo\"` never closes and every following argument is swallowed by the
|
||||||
|
preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore
|
||||||
|
fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff:
|
||||||
|
the CLI warned `brief.md is not a directory` and the session opened with no prompt at all
|
||||||
|
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through
|
||||||
|
`TrimTrailingSeparator`; session dirs the worker builds never carry one.
|
||||||
|
|
||||||
|
The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"` →
|
||||||
|
`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side:
|
||||||
|
**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**,
|
||||||
|
which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) —
|
||||||
|
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
|
||||||
|
a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the
|
||||||
|
`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string.
|
||||||
|
|
||||||
|
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
|
||||||
|
every repro passes. Read the real command line instead:
|
||||||
|
`Get-CimInstance Win32_Process -Filter "Name = 'claude.exe'"`.
|
||||||
|
|
||||||
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
|
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
|
||||||
|
|
||||||
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
|
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
|
||||||
@@ -76,9 +99,30 @@ launch even started.
|
|||||||
|
|
||||||
## List handler ("Let Claude handle it")
|
## List handler ("Let Claude handle it")
|
||||||
|
|
||||||
`BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The
|
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
|
||||||
`--allowedTools` allowlist is the security boundary:
|
`PermissionModeResolver` (currently always `auto`, since none of the handler roles run on haiku)
|
||||||
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
|
so the session runs unattended. The `--allowedTools` allowlist is the security boundary:
|
||||||
|
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task` (`Task` lets the Merge
|
||||||
|
role delegate diff reviews to sonnet subagents).
|
||||||
|
|
||||||
|
The run now spans up to **five** phase-scoped sessions instead of two, chained via
|
||||||
|
`handoff_list_handler(taskId, survivingTaskIds, nextPhase)`: opus Triage → sonnet Wait → opus
|
||||||
|
Merge → (only if Merge started reruns) sonnet Wait(final) → opus Merge(final). `nextPhase` (`wait`
|
||||||
|
| `merge` | `wait_final` | `merge_final`, validated by `MergeHelperPhase.Validate`) picks both the
|
||||||
|
system prompt (`PromptKind.MergeHelperWait`/`MergeHelperMerge`) and the model
|
||||||
|
(`ModelRegistry.HandlerWaitAlias`/`HandlerMergeAlias`) for the next session; the `_final` variants
|
||||||
|
share the same prompt/model as their non-final counterpart and differ only in one extra line
|
||||||
|
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
|
||||||
|
and forbidding further reruns.
|
||||||
|
|
||||||
|
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
|
||||||
|
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
|
||||||
|
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
|
||||||
|
the next one — every tile is a live process with the full `mcp__claudedo__*` surface, and the
|
||||||
|
outgoing session is only ever told to end its turn, never to exit. This keeps the one-pane-per-
|
||||||
|
`TaskId` invariant that `ConPtySessions` relies on elsewhere (`OpenConPtySessionAsync`'s dedupe,
|
||||||
|
`OnPaneSubmitForReview`) — both use a plain `FirstOrDefault(s => s.TaskId == taskId)`, not a
|
||||||
|
"newest wins" lookup.
|
||||||
|
|
||||||
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
|
||||||
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
comfortably under it (see [external-mcp.md](external-mcp.md)).
|
||||||
@@ -218,7 +262,9 @@ Autonomous and interactive sessions do **not** share a system prompt. Per start
|
|||||||
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
|
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
|
||||||
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart` → `WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
|
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart` → `WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
|
||||||
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume` → `WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
|
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume` → `WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
|
||||||
| List handler ("Let Claude handle it") | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelper`), always fresh — this path never resumes |
|
| List handler ("Let Claude handle it"), triage | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperTriage` — phases 0–2 only), always fresh — this path never resumes |
|
||||||
|
| List handler, post-handoff (wait/wait_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperWait` — phase 3 only), fresh session dir, same handler task id |
|
||||||
|
| List handler, post-handoff (merge/merge_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperMerge` — phases 4–5 only), fresh session dir, same handler task id |
|
||||||
|
|
||||||
So every interactive resume (task session and planning) drops the system prompt entirely — it's
|
So every interactive resume (task session and planning) drops the system prompt entirely — it's
|
||||||
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
|
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# External MCP tool surface
|
# External MCP tool surface
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `20bce9b` (2026-08-06).
|
> Last verified against commit `38af549` (2026-08-11).
|
||||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/External`
|
> Drift check: `git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/External`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
|
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
|
||||||
@@ -14,11 +14,34 @@ need no `--mcp-config`.
|
|||||||
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
|
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
|
||||||
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
|
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
|
||||||
|
|
||||||
## Hard conventions (enforced by tests)
|
## Task ID resolution and numbering
|
||||||
|
|
||||||
|
Every tool parameter accepting a task id — `taskId`, `parentId`, `taskIds` arrays, and similar —
|
||||||
|
is wired through `TaskIdResolver`, which resolves:
|
||||||
|
- `#123` (with or without the `#` prefix) → look up by `TaskEntity.Number`
|
||||||
|
- Bare GUID string → use as-is
|
||||||
|
- Unknown number → `InvalidOperationException` (`no task with number 123`)
|
||||||
|
|
||||||
|
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
|
||||||
|
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
|
||||||
|
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
|
||||||
|
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
|
||||||
|
continue to use the GUID; the number is a display alias, never the identity.
|
||||||
|
|
||||||
|
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
|
||||||
|
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user** —
|
||||||
|
without this the agent sees the number in every payload but never learns to speak it.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Test-enforced
|
||||||
|
|
||||||
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
|
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
|
||||||
The MCP schema only marks a parameter optional when it has one — nullability alone does
|
The MCP schema only marks a parameter optional when it has one — nullability alone does
|
||||||
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
|
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
|
||||||
|
|
||||||
|
### Not test-enforced, but strongly observed
|
||||||
|
|
||||||
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
|
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
|
||||||
an empty/omitted response apart from a dropped one.
|
an empty/omitted response apart from a dropped one.
|
||||||
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
|
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
|
||||||
@@ -32,7 +55,16 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
|
|||||||
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
|
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
|
||||||
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
|
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
|
||||||
list of verbosely-described tasks from blowing past the response size limit by default.
|
list of verbosely-described tasks from blowing past the response size limit by default.
|
||||||
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
|
||||||
|
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
|
||||||
|
lives there as `const` strings. Rules: the first sentence says what the tool does *and* when
|
||||||
|
to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind
|
||||||
|
return-shape prose); parameters are documented with `[Description]` **on the parameter**, not
|
||||||
|
in the tool description; result fields appear only where the caller must branch on them
|
||||||
|
before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or
|
||||||
|
"since this feature was introduced" history.
|
||||||
|
|
||||||
|
4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
|
||||||
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
|
||||||
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
|
||||||
*"An error occurred invoking 'X'."*
|
*"An error occurred invoking 'X'."*
|
||||||
@@ -42,8 +74,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
|
|||||||
### `ExternalMcpService` — task CRUD, execution, git
|
### `ExternalMcpService` — task CRUD, execution, git
|
||||||
|
|
||||||
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
|
||||||
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
|
`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`.
|
||||||
`CancelTask`, `DeleteTask`.
|
(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s
|
||||||
|
description is now the canonical place for what each status means.)
|
||||||
|
|
||||||
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
|
||||||
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
|
||||||
@@ -137,7 +170,16 @@ directory is shared with other concurrent sessions.
|
|||||||
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
|
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
|
||||||
failing item never aborts the rest — and rejects batches over **100 items**.
|
failing item never aborts the rest — and rejects batches over **100 items**.
|
||||||
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
|
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
|
||||||
`BatchGetTaskResult` carries `task` (lean) or `taskFull` (full), never both.
|
`BatchGetTaskResult` carries `task` (lean `TaskRefDto`) or `taskFull`, never both. `taskFull` is
|
||||||
|
`BatchTaskDetailDto` — a batch-only shape, **not** `TaskDto` (`get_task` is untouched) — whose
|
||||||
|
Description/Result are cut to `descriptionMaxChars` (default 1500, was unlimited) with
|
||||||
|
`*Truncated`/`*FullLength` flagging it, and which `fields` (an optional name allow-list, e.g.
|
||||||
|
`['title','roadblockText']`) can narrow further; unrequested fields come back `null`.
|
||||||
|
`roadblockText` pulls just the bullet lines after `TaskRunner.ComposeReviewResult`'s roadblock
|
||||||
|
marker out of `Result`, without needing the rest of it. The whole per-call response is also
|
||||||
|
capped (`BatchMcpTools.MaxResponseChars`) — over that, the call throws naming which parameter to
|
||||||
|
adjust instead of shipping an oversized payload (the incident that prompted this: 8 tasks'
|
||||||
|
full Description/Result serialized to a single 51k-char line).
|
||||||
|
|
||||||
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
|
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# List virtualization spike (Phase 2a gate)
|
||||||
|
|
||||||
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
|
> Last verified against commit `6a2a19c` (2026-08-10).
|
||||||
|
> Drift check: `git log --oneline 6a2a19c..HEAD -- src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs`
|
||||||
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
|
Gate task for [ui-reaktivitaet-und-listen-performance-design](../superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md)
|
||||||
|
Phase 2, Risk 1. Answers the five open questions before any rework of the real
|
||||||
|
`TasksIslandView`/`TasksIslandViewModel`. No production code changed for this spike.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
A throwaway Avalonia-headless xUnit test (`Avalonia.Headless` + `Avalonia.Themes.Fluent`,
|
||||||
|
temporarily added to `ClaudeDo.Ui.Tests`, removed again after this report was written — not
|
||||||
|
committed) built a plain `ListBox` with an explicit `VirtualizingStackPanel` and 1000 mixed
|
||||||
|
items (≈14% "complex" two-line/badge rows, mirroring the 68px/90–110px split from the design
|
||||||
|
doc), then drove it programmatically: forced layout/render passes, scrolled the `ScrollViewer`,
|
||||||
|
called the same `TopLevel.InputHitTest` the production drag code uses, and counted realized
|
||||||
|
`ListBoxItem` containers via the visual tree. No screenshots — every number below came from an
|
||||||
|
actual headless run of that harness on 2026-08-10.
|
||||||
|
|
||||||
|
One harness-only wrinkle: `InputHitTest` walks the **compositor's rendered scene**, not just the
|
||||||
|
layout tree, so the test had to call `window.CaptureRenderedFrame()` after each layout-affecting
|
||||||
|
change before hit-testing. A real windowed app renders every frame continuously, so this isn't a
|
||||||
|
production concern — noted here only so the method is reproducible.
|
||||||
|
|
||||||
|
## Q1 — Does a virtualized `ListBox` actually bound the number of realized containers at 1000 items?
|
||||||
|
|
||||||
|
**Geht.** Realized `ListBoxItem` count stayed at 22–23 across the whole scroll range (top, 25%,
|
||||||
|
50%, 75%, bottom) with 1000 source items in the list:
|
||||||
|
|
||||||
|
```
|
||||||
|
realized containers @ top: 22
|
||||||
|
realized containers @ 25% scroll: 22
|
||||||
|
realized containers @ 50% scroll: 22
|
||||||
|
realized containers @ 75% scroll: 23
|
||||||
|
realized containers @ 100% scroll: 22
|
||||||
|
```
|
||||||
|
|
||||||
|
Matches the design doc's estimate (~19 visible + overscan ≈ 22–25) almost exactly. The
|
||||||
|
`ListBox`/`VirtualizingStackPanel` combination genuinely recycles containers instead of building
|
||||||
|
one per item.
|
||||||
|
|
||||||
|
## Q2 — Does the existing ghost-drag `InputHitTest` model survive container recycling?
|
||||||
|
|
||||||
|
**Geht.** Scrolled from top to 60% (forcing recycling), then called `TopLevel.InputHitTest` at the
|
||||||
|
*same screen point* both times — exactly what `RowButtonAt`/`ListItemUnder` in
|
||||||
|
`TasksIslandView.axaml.cs` do live during a drag:
|
||||||
|
|
||||||
|
```
|
||||||
|
hit-test @ top, screen point (450,600): Task[8]
|
||||||
|
hit-test @ 60% scroll, same screen point: Task[577]
|
||||||
|
expected DataContext at that point (geometry): Task[577]
|
||||||
|
```
|
||||||
|
|
||||||
|
The hit-test result matches the geometrically-correct row after recycling, and correctly changed
|
||||||
|
(it did *not* keep returning the stale top-of-list row). The production pattern —
|
||||||
|
`topLevel.InputHitTest(pt)` then walk ancestors to the row `Button`/`ListBoxItem` — is
|
||||||
|
recycling-safe: it's a live geometric query against whatever is currently realized, with no
|
||||||
|
per-row state to go stale.
|
||||||
|
|
||||||
|
## Q3 — Variable row heights (68px vs 90–110px): does the scrollbar/`Extent` estimate drift?
|
||||||
|
|
||||||
|
**Geht, mit einer Einschränkung.** Measured actual template heights in this harness: header
|
||||||
|
57px, simple task row 53px, complex (two-line + badges) row 90px. Hand-summing all 1000 rows by
|
||||||
|
their real measured height gives 58275px; `VirtualizingStackPanel`'s reported `Extent.Height` was
|
||||||
|
58228px — **0.1% error**. Avalonia's own extent bookkeeping for variable-height virtualized rows
|
||||||
|
is not the "gross estimate that drifts" risk the design doc worried about.
|
||||||
|
|
||||||
|
The real risk is a **hand-rolled** pixel↔index conversion. Scrolling to `index * 68px` (a naive
|
||||||
|
"assume every row is 68px" offset) while targeting logical row #700 actually landed on row
|
||||||
|
**#790** — a 90-row drift, because ~14% of the preceding rows were taller. Anything that
|
||||||
|
computes a scroll offset from `index * fixedRowHeight` (a natural shortcut for a "scroll to
|
||||||
|
selected row" or a custom auto-scroll speed curve) will misfire. The existing
|
||||||
|
`ScrollSelectedIntoView` in `TasksIslandView.axaml.cs` already avoids this trap — it finds the
|
||||||
|
realized control by `DataContext` and calls `BringIntoView()`, not index-times-height math. Phase
|
||||||
|
2b must keep using `ListBox.ScrollIntoView(item)` / `Control.BringIntoView()`, never index
|
||||||
|
arithmetic, for any "jump to row" behavior.
|
||||||
|
|
||||||
|
## Q4 — Is auto-scroll-while-dragging feasible with the existing central-pointer-handler model?
|
||||||
|
|
||||||
|
**Geht.** Auto-scroll doesn't exist today and has to be built, but the mechanism it needs — the
|
||||||
|
same UI-thread pointer-moved handler (or a `DispatcherTimer` it starts) mutating
|
||||||
|
`ScrollViewer.Offset` directly while a drag is in progress — was exercised directly: nudging
|
||||||
|
`Offset` by 400px in one synchronous step kept the realized-container count constant (22 before,
|
||||||
|
22 after) and an immediate subsequent `InputHitTest` at the same screen point resolved correctly
|
||||||
|
to the new row under the cursor (`Task[388]`), with no async delay or stale state. Since
|
||||||
|
`TasksIslandView`'s drag handlers already run on the UI thread via tunnelled pointer events, a
|
||||||
|
`DispatcherTimer` (or a direct `Offset` nudge inside `OnPointerMovedDrag` when the cursor is near
|
||||||
|
the list's top/bottom edge) has no threading obstacle. This still needs to be built in Phase 2b;
|
||||||
|
this spike only confirms the approach isn't blocked by virtualization/recycling.
|
||||||
|
|
||||||
|
## Q5 — Mixed item types (header rows vs. task rows) via a template selector, under virtualization
|
||||||
|
|
||||||
|
**Geht.** Used `ListBox.DataTemplates` with two `FuncDataTemplate<T>` entries keyed by type
|
||||||
|
(`HeaderRow` / `TaskRow`) instead of an explicit `IDataTemplate.Match` selector — Avalonia's
|
||||||
|
implicit per-type template lookup is the idiomatic equivalent and needs no selector class. Both
|
||||||
|
types showed up among the realized containers throughout scrolling
|
||||||
|
(`Q5 distinct realized DataContext types: HeaderRow, TaskRow`), confirming the design's flat
|
||||||
|
`Rows` (`HeaderRow | TaskRowViewModel`) collection will virtualize and template correctly.
|
||||||
|
|
||||||
|
## Recommendation for Phase 2b
|
||||||
|
|
||||||
|
**Geht — proceed**, with two carry-overs into the implementation:
|
||||||
|
|
||||||
|
1. Any "scroll to row" logic (selection scroll, auto-scroll speed/targeting) must go through
|
||||||
|
`ScrollIntoView`/`BringIntoView` against the realized control, never `index * rowHeight` math
|
||||||
|
(Q3).
|
||||||
|
2. Auto-scroll is new work, not a retrofit risk — build it as an `Offset` nudge from the existing
|
||||||
|
UI-thread drag handlers (Q4).
|
||||||
|
|
||||||
|
No evidence surfaced against the Phase 2 design as written. The `ITaskListFilter` risk (#3 in the
|
||||||
|
design doc) is unrelated to virtualization and out of this spike's scope.
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# Review, merge & conflict resolution
|
# Review, merge & conflict resolution
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `0d1e3b9` (2026-08-06).
|
> Last verified against `fc9df7f` (2026-08-11), which added the conventional merge-commit default
|
||||||
|
> and the `MergeProgress` broadcast.
|
||||||
|
> Planning mode renders per file and `DiffLinesView` is retired.
|
||||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
@@ -81,6 +83,17 @@ gating `CanMergeAll`. The worker-side guard remains the actual correctness fix;
|
|||||||
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
|
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
|
||||||
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
|
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
|
||||||
|
|
||||||
|
## Merge commit message
|
||||||
|
|
||||||
|
`MergeAsync` takes a `commitMessage`, but **every caller passes it blank** and lets
|
||||||
|
`TaskMergeService.DefaultMergeMessage` build `CommitMessageBuilder.BuildMerge(task.CommitType,
|
||||||
|
list.Name, task.Title, task.Id)` → `fix(my-list): merge <title>` + `ClaudeDo-Task:` trailer. Same
|
||||||
|
`type(scope)` shape as the task's own worktree commits, so merges stay Conventional-Commits-clean.
|
||||||
|
The only non-blank caller is the user editing the field in the merge modal, which is prefilled from
|
||||||
|
`GetMergeTargets().DefaultCommitMessage` (the UI can't build it — it knows neither the commit type
|
||||||
|
nor the list name). Callers used to hand-roll `"Merge task: {title}"` / `"Merge {branch}"` /
|
||||||
|
`"Merge subtask"`; don't add a fourth.
|
||||||
|
|
||||||
## Post-merge verify gate
|
## Post-merge verify gate
|
||||||
|
|
||||||
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
|
||||||
@@ -109,11 +122,40 @@ but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, wo
|
|||||||
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
|
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
|
||||||
the target branch at once would be the one run nothing checks.
|
the target branch at once would be the one run nothing checks.
|
||||||
|
|
||||||
|
**The gate is what makes a merge look broken.** A verify command that builds and tests a solution
|
||||||
|
occupies the whole `MergeTask` hub call — measured at ~5m 46s on this repo. `TaskMergeService`
|
||||||
|
therefore broadcasts `MergeProgress(taskId, phase, elapsedSeconds)`: `PhaseMerging` before the
|
||||||
|
per-repo gate wait, `PhaseVerifying` when the verify starts and again on every
|
||||||
|
`ProgressReportInterval` tick (30 s, shared with the MCP progress reports), plus one
|
||||||
|
`WorkerLog` Info line so the footer strip shows it too. The merge modal renders that as a spinner
|
||||||
|
plus phase text; without it the only feedback was the disabled Submit button, which reads as a
|
||||||
|
dead app for minutes while the merge has in fact already landed.
|
||||||
|
|
||||||
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
|
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
|
||||||
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
|
`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
|
so a verify run can't be interrupted by a second merge landing in the same working dir
|
||||||
mid-build.
|
mid-build.
|
||||||
|
|
||||||
|
### Verify in the preview, not just post-merge
|
||||||
|
|
||||||
|
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
|
||||||
|
clean `git merge-tree` result *before* anything is merged. It never touches the real working tree:
|
||||||
|
the tree `PreviewMergeAsync` would write is wrapped in a throwaway commit
|
||||||
|
(`GitService.CommitTreeAsync`, parent = the target branch's current tip) and checked out into a
|
||||||
|
detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDetachedAsync`), which
|
||||||
|
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
|
||||||
|
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
|
||||||
|
|
||||||
|
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
|
||||||
|
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
|
||||||
|
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
|
||||||
|
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
|
||||||
|
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
|
||||||
|
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
|
||||||
|
not run; `-1` = timed out or failed to start; mirrors the post-merge gate's convention),
|
||||||
|
`VerifyDurationMs`, `VerifyOutputTail` (tail of output, only populated on non-zero exit, via the
|
||||||
|
same `TailOutput` helper the post-merge gate uses).
|
||||||
|
|
||||||
## `MergeCommit` and revert
|
## `MergeCommit` and revert
|
||||||
|
|
||||||
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
|
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
|
||||||
@@ -219,15 +261,20 @@ Review **Approve** on conflict, and the **Merge** button in the Diff window (a c
|
|||||||
## Diff stack (UI)
|
## Diff stack (UI)
|
||||||
|
|
||||||
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
|
`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
|
added/deleted/renamed/binary files and per-line numbers. `DiffModels.cs` holds the shared types
|
||||||
a combined single-pane view. `DiffModels.cs` holds the shared types (`DiffLineViewModel`,
|
(`DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
|
||||||
`DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
|
|
||||||
`DiffTreeNodeViewModel`, `DiffTree`).
|
`DiffTreeNodeViewModel`, `DiffTree`).
|
||||||
|
|
||||||
`DiffViewerViewModel` is one unified read-only viewer with two modes:
|
`DiffViewerViewModel` is one unified read-only viewer with two modes:
|
||||||
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
|
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
|
||||||
file-tree left + per-file diff pane right, Merge button for a live branch source.
|
file-tree left + per-file diff pane right, Merge button for a live branch source.
|
||||||
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat
|
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + one
|
||||||
diff right, combined integration-branch toggle.
|
editor per file right (`PlanningFiles`), combined integration-branch toggle.
|
||||||
|
|
||||||
`DiffLinesView` renders per-file content with binary/empty placeholders.
|
Both modes render through `DiffAlignment` (pure — pairs diff lines into side-by-side rows and
|
||||||
|
computes word-diff spans) and `DiffTextView` (AvaloniaEdit + TextMate highlighting keyed off the
|
||||||
|
file extension, unified/split layout, optional line wrap, synced scrolling). Files mode hosts one
|
||||||
|
`DiffTextView` for the selected file; Planning mode hosts one per file in `PlanningFiles` so each
|
||||||
|
gets its own grammar (one editor can only carry one TextMate grammar). The split/wrap toggles
|
||||||
|
persist to `ui.config.json` via `AppSettings` and reach the per-file editors in Planning mode
|
||||||
|
too.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Usage monitoring, gate & throttle
|
# Usage monitoring, gate & throttle
|
||||||
|
|
||||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||||
> Last verified against commit `f6cb825` (2026-08-05).
|
> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
|
||||||
|
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
|
||||||
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
|
||||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||||
|
|
||||||
@@ -52,21 +53,26 @@ on block / Info on resume) exactly **once per change**, not every tick.
|
|||||||
|
|
||||||
## The throttle (staged parallelism)
|
## The throttle (staged parallelism)
|
||||||
|
|
||||||
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
|
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
|
||||||
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
|
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
|
||||||
|
per-bucket triple (same file).
|
||||||
|
|
||||||
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
|
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
|
||||||
Whichever of 5h/7d is **more utilized** decides the stage:
|
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
|
||||||
|
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
|
||||||
|
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
|
||||||
|
that throttles:
|
||||||
|
|
||||||
| Utilization | Effective slots |
|
| Utilization (per bucket) | That bucket's slots |
|
||||||
|---|---|
|
|---|---|
|
||||||
| below soft | full configured `max_parallel_executions` |
|
| below soft | full configured `max_parallel_executions` |
|
||||||
| `>= softPct` | capped at 2 |
|
| `>= softPct` | capped at 2 |
|
||||||
| `>= hardPct` | capped at 1 |
|
| `>= hardPct` | capped at 1 |
|
||||||
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
|
| `>= gatePct` | 0 — same hard block as `UsageGate` |
|
||||||
|
|
||||||
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
|
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
|
||||||
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
|
throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
|
||||||
|
read the same gate thresholds — change one, change both.
|
||||||
|
|
||||||
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
|
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
|
||||||
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
runs to completion. Same fail-open policy: no snapshot means no throttling.
|
||||||
@@ -108,6 +114,12 @@ A missing/unreadable transcript leaves all four fields `null`; it never fails th
|
|||||||
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
|
||||||
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
Other), deduped by `requestId`, with a per-file length+mtime cache.
|
||||||
|
|
||||||
|
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
|
||||||
|
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
|
||||||
|
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
|
||||||
|
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
|
||||||
|
unaffected — it looks up a single `{sessionId}.jsonl`.
|
||||||
|
|
||||||
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
|
||||||
|
|
||||||
## UI surfaces
|
## UI surfaces
|
||||||
@@ -117,6 +129,25 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
|
|||||||
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
|
||||||
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
|
||||||
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
|
||||||
|
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
|
||||||
|
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
|
||||||
|
the first `GetModelUsage` per worker process scans the whole transcript history.
|
||||||
|
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
|
||||||
|
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
|
||||||
|
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
|
||||||
|
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
|
||||||
|
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
|
||||||
|
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
|
||||||
|
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
|
||||||
|
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
|
||||||
|
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
|
||||||
|
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
|
||||||
|
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
|
||||||
|
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
|
||||||
|
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
|
||||||
|
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||||||
|
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
|
||||||
|
doesn't replace the bound instance.
|
||||||
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
|
||||||
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
|
||||||
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
|
||||||
@@ -138,5 +169,13 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
|
|||||||
## Settings columns
|
## Settings columns
|
||||||
|
|
||||||
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
|
||||||
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by
|
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
|
||||||
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
|
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
|
||||||
|
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
|
||||||
|
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
|
||||||
|
`usage_poll_interval_idle_seconds`.
|
||||||
|
|
||||||
|
The gate percentages are editable in **two** places that both write the same `app_settings` row:
|
||||||
|
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||||
|
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
|
||||||
|
can't reset a dragged value.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ read-only "## Reference files" section.
|
|||||||
- `TaskMergeService` — conflict resolution for worktree merges.
|
- `TaskMergeService` — conflict resolution for worktree merges.
|
||||||
|
|
||||||
**Hub/**
|
**Hub/**
|
||||||
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
|
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…).
|
||||||
- `WorkerHub` — SignalR hub + client methods.
|
- `WorkerHub` — SignalR hub + client methods.
|
||||||
|
|
||||||
**Agents/**
|
**Agents/**
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran
|
|||||||
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
|
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
|
||||||
`ping` for timeout) were exercised.
|
`ping` for timeout) were exercised.
|
||||||
|
|
||||||
|
## Offene Verifikation (2026-08-11, Task numbers in UI)
|
||||||
|
|
||||||
|
Slice 4 der Task-Numbers-Features (UI-Display) ist gemerged (commit 38af549); Build + Tests grün,
|
||||||
|
aber **nicht visuell verifiziert**:
|
||||||
|
|
||||||
|
- **Task Row Display:** `TaskRowViewModel.Number` (Zeile 39, TaskRowViewModel.cs) zeigt die
|
||||||
|
Nummer als `#<number>` dimmed vor dem Titel in der Row an (`TaskRowView.axaml` Zeile 45+). Prüfen:
|
||||||
|
offene Task in der Übersicht hat sichtbar `#<number>` vor dem Titel.
|
||||||
|
- **Detail Pane Header:** `DetailsIslandViewModel.TaskIdBadge` (Zeile 77, DetailsIslandViewModel.cs)
|
||||||
|
zeigt jetzt `#<number>` statt des alten GUID-Präfix. Prüfen: Task-Detail-Chip zeigt
|
||||||
|
`#<number>`.
|
||||||
|
- **Worker Log Messages:** Geschäftsereignisse in `TaskRunner` (Zeile 199+), `TaskMergeService`
|
||||||
|
(Zeile 174+), und `TaskResetService` (Zeile 84+) prefixen Task-Titel mit `#<number>`. Prüfen:
|
||||||
|
Footer Worker-Log zeigt Task-Messages wie „`#42 finished`" statt nur dem GUID.
|
||||||
|
|
||||||
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
|
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
|
||||||
|
|
||||||
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
|
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
|||||||
|
# Handler-Run Links Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
|
||||||
|
|
||||||
|
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
|
||||||
|
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
|
||||||
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `SetHandlerTaskIdAsync`
|
||||||
|
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
|
||||||
|
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `HandlerBadgeBrush` + `Border.badge.handler`
|
||||||
|
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `tasks.badgeHandler`, `tasks.handlerTip`
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `HandledTasks` collection, loader, clear, refresh
|
||||||
|
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
|
||||||
|
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
|
||||||
|
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data — `HandlerTaskId` column and migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
|
||||||
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
|
||||||
|
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the property**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
|
||||||
|
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
|
||||||
|
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
|
||||||
|
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
|
||||||
|
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
|
||||||
|
public string? HandlerTaskId { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Map the column and index it**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
|
||||||
|
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
At the end of `Configure`, after the line
|
||||||
|
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration**
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
|
||||||
|
|
||||||
|
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
|
||||||
|
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
|
||||||
|
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
|
||||||
|
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
||||||
|
Expected: `Build succeeded`, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
|
||||||
|
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
|
||||||
|
main checkout is shared with concurrent sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||||
|
|
||||||
|
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
|
||||||
|
/// run processed, so the handler task's detail pane can list them after the run.
|
||||||
|
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
|
||||||
|
public TaskRepositoryHandlerLinkTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> CreateListAsync()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId,
|
||||||
|
Name = "Test List",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return listId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> AddTaskAsync(string listId)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _tasks.AddAsync(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
ListId = listId,
|
||||||
|
Title = "T",
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var b = await AddTaskAsync(listId);
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(2, affected);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
|
||||||
|
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(
|
||||||
|
new[] { a, "does-not-exist" }, handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(1, affected);
|
||||||
|
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var a = await AddTaskAsync(listId);
|
||||||
|
var firstHandler = await AddTaskAsync(listId);
|
||||||
|
var secondHandler = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
|
||||||
|
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
|
||||||
|
|
||||||
|
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
|
||||||
|
{
|
||||||
|
var listId = await CreateListAsync();
|
||||||
|
var handlerId = await AddTaskAsync(listId);
|
||||||
|
|
||||||
|
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
|
||||||
|
|
||||||
|
Assert.Equal(0, affected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||||
|
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the method**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Links the tasks a "list handler" run processed back to the handler's own task, so the
|
||||||
|
// handler's detail pane can list them after the run. Stamped from the user's selection at
|
||||||
|
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
|
||||||
|
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
|
||||||
|
public async Task<int> SetHandlerTaskIdAsync(
|
||||||
|
IReadOnlyList<string> taskIds,
|
||||||
|
string handlerTaskId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (taskIds.Count == 0) return 0;
|
||||||
|
|
||||||
|
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return 0;
|
||||||
|
|
||||||
|
return await _context.Tasks
|
||||||
|
.Where(t => ids.Contains(t.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 4`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Worker — stamp the selection when the handler task is created
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
|
||||||
|
|
||||||
|
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
|
||||||
|
`IWorkerClient` → `WorkerHub` chain needs **no** change.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
|
||||||
|
var t1 = Guid.NewGuid().ToString();
|
||||||
|
var t2 = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||||
|
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||||
|
|
||||||
|
var svc = BuildService();
|
||||||
|
var handlerId = await svc.CreateMergeHelperTaskAsync(
|
||||||
|
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
|
||||||
|
|
||||||
|
using var readCtx = _db.CreateContext();
|
||||||
|
var tasks = new TaskRepository(readCtx);
|
||||||
|
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
|
||||||
|
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
|
||||||
|
// The handler never links to itself.
|
||||||
|
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
|
||||||
|
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Stamp the selection**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await taskRepo.AddAsync(handlerTask, ct);
|
||||||
|
|
||||||
|
return handlerTask.Id;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await taskRepo.AddAsync(handlerTask, ct);
|
||||||
|
|
||||||
|
// Link the selection back to this run BEFORE the session starts: the handler cancels
|
||||||
|
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
|
||||||
|
// list. Stamping later (e.g. at handoff) would lose them.
|
||||||
|
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
|
||||||
|
|
||||||
|
return handlerTask.Id;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
|
||||||
|
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Ui — HANDLER badge instead of MANUAL
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
|
||||||
|
/// it -- the HANDLER badge must win and MANUAL must disappear.
|
||||||
|
public class TaskRowViewModelHandlerBadgeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t1" };
|
||||||
|
row.IsManual = true;
|
||||||
|
row.HandlerBaseCommit = "base123";
|
||||||
|
|
||||||
|
Assert.True(row.IsHandlerRun);
|
||||||
|
Assert.NotNull(row.HandlerBadge);
|
||||||
|
Assert.Null(row.ManualBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlainManualTask_StillShowsManualBadge()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t2" };
|
||||||
|
row.IsManual = true;
|
||||||
|
|
||||||
|
Assert.False(row.IsHandlerRun);
|
||||||
|
Assert.Null(row.HandlerBadge);
|
||||||
|
Assert.NotNull(row.ManualBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
|
||||||
|
{
|
||||||
|
var row = new TaskRowViewModel { Id = "t3" };
|
||||||
|
row.UpdateFromEntity(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "t3",
|
||||||
|
ListId = "l1",
|
||||||
|
Title = "List handler: Alpha",
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
IsManual = true,
|
||||||
|
HandlerBaseCommit = "base123",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("base123", row.HandlerBaseCommit);
|
||||||
|
Assert.True(row.IsHandlerRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||||
|
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the properties**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
|
||||||
|
declaration (`[ObservableProperty] private bool _isManual;`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
|
||||||
|
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
|
||||||
|
[ObservableProperty] private string? _handlerBaseCommit;
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||||
|
|
||||||
|
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||||
|
|
||||||
|
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
|
||||||
|
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
|
||||||
|
public bool ShowManualBadge => IsManual && !IsHandlerRun;
|
||||||
|
|
||||||
|
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a change hook next to the existing `OnIsManualChanged` partial method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
partial void OnHandlerBaseCommitChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsHandlerRun));
|
||||||
|
OnPropertyChanged(nameof(HandlerBadge));
|
||||||
|
OnPropertyChanged(nameof(ShowManualBadge));
|
||||||
|
OnPropertyChanged(nameof(ManualBadge));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(ShowManualBadge));
|
||||||
|
```
|
||||||
|
|
||||||
|
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
HandlerBaseCommit = t.HandlerBaseCommit;
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(HandlerBadge));
|
||||||
|
OnPropertyChanged(nameof(ManualBadge));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the locale keys**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"badgeHandler": "HANDLER",
|
||||||
|
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
|
||||||
|
```
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"badgeHandler": "HANDLER",
|
||||||
|
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the badge style and brush**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
|
||||||
|
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
After the existing `Border.badge.manual` style block add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
|
||||||
|
<Style Selector="Border.badge.handler">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
|
||||||
|
</Style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Render the badge**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||||
|
<TextBlock Text="{Binding ManualBadge}"/>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||||
|
<TextBlock Text="{Binding ManualBadge}"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
|
||||||
|
<TextBlock Text="{Binding HandlerBadge}"/>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the `IsVisible` binding changed on the manual border (`IsManual` → `ShowManualBadge`); the
|
||||||
|
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 3`.
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: `Passed! - Failed: 0` (en/de key parity).
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: `Build succeeded` — this compiles the AXAML.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
|
||||||
|
/// its id, including ones the handler cancelled as duplicates.
|
||||||
|
public class DetailsIslandHandledTasksTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandHandledTasksTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_dbPath); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||||
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext NewContext()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
return new ClaudeDoDbContext(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||||
|
{
|
||||||
|
private readonly Func<ClaudeDoDbContext> _create;
|
||||||
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||||
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||||
|
{
|
||||||
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||||
|
Task.FromResult(new List<DailyNoteDto>());
|
||||||
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||||
|
Task.FromResult<DailyNoteDto?>(null);
|
||||||
|
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetailsIslandViewModel BuildVm()
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(
|
||||||
|
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
|
||||||
|
{
|
||||||
|
const string listId = "list-1";
|
||||||
|
const string handlerId = "handler-task-1";
|
||||||
|
|
||||||
|
await using (var ctx = NewContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = handlerId, ListId = listId, Title = "List handler: L",
|
||||||
|
Status = TaskStatus.WaitingForReview, IsManual = true,
|
||||||
|
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "done-1", ListId = listId, Title = "Merged task",
|
||||||
|
Status = TaskStatus.Done, HandlerTaskId = handlerId,
|
||||||
|
SortOrder = 0, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
|
||||||
|
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
|
||||||
|
SortOrder = 1, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
Assert.Equal(2, vm.HandledTasks.Count);
|
||||||
|
Assert.True(vm.HasHandledTasks);
|
||||||
|
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
|
||||||
|
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
|
||||||
|
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Bind_PlainTask_HasNoHandledTasks()
|
||||||
|
{
|
||||||
|
const string listId = "list-1";
|
||||||
|
const string taskId = "plain-1";
|
||||||
|
|
||||||
|
await using (var ctx = NewContext())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId, ListId = listId, Title = "Plain",
|
||||||
|
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = BuildVm();
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
Assert.Empty(vm.HandledTasks);
|
||||||
|
Assert.False(vm.HasHandledTasks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||||
|
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the collection**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
|
||||||
|
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
|
||||||
|
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
|
||||||
|
// planning/improvement parent's children and feeds the merge card's combined diff, which a
|
||||||
|
// handler run must not touch (it commits straight to the list's working dir).
|
||||||
|
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool HasHandledTasks => HandledTasks.Count > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clear it on rebind**
|
||||||
|
|
||||||
|
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
HandledTasks.Clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
OnPropertyChanged(nameof(HasHandledTasks));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Load it on bind**
|
||||||
|
|
||||||
|
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await LoadHandledTasksAsync(row.Id, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
|
||||||
|
// reads in the same order the user picked them.
|
||||||
|
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var handled = await ctx.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.Where(t => t.HandlerTaskId == handlerTaskId)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (handled.Count == 0) return;
|
||||||
|
|
||||||
|
HandledTasks.Clear();
|
||||||
|
foreach (var h in handled)
|
||||||
|
HandledTasks.Add(new ChildOutcomeRowViewModel
|
||||||
|
{
|
||||||
|
Id = h.Id,
|
||||||
|
Title = h.Title,
|
||||||
|
Status = h.Status,
|
||||||
|
RoadblockCount = h.RoadblockCount,
|
||||||
|
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
|
||||||
|
});
|
||||||
|
OnPropertyChanged(nameof(HasHandledTasks));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Keep the rows live**
|
||||||
|
|
||||||
|
In the same file, in `RefreshChildOutcomeAsync`, replace:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
|
||||||
|
if (row is null) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// The same refresh serves both lists: a planning parent's children and a handler run's
|
||||||
|
// handled tasks. Only one of them can hold a given id.
|
||||||
|
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
|
||||||
|
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
|
||||||
|
if (row is null) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Render the panel**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
|
||||||
|
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Handled tasks (list handler run) -->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
|
||||||
|
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
|
||||||
|
<ItemsControl ItemsSource="{Binding HandledTasks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||||
|
IsVisible="{Binding HasRoadblock}"
|
||||||
|
Foreground="#E0A030"
|
||||||
|
Margin="8,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||||
|
Opacity="0.75" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||||
|
Expected: `Passed! - Failed: 0, Passed: 2`.
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: `Build succeeded`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
|
||||||
|
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Full verification and docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
|
||||||
|
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
|
||||||
|
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run every affected test project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
|
||||||
|
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
|
||||||
|
|
||||||
|
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
|
||||||
|
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
|
||||||
|
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
|
||||||
|
|
||||||
|
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
|
||||||
|
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
|
||||||
|
|
||||||
|
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
|
||||||
|
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
|
||||||
|
|
||||||
|
In the "The host task and its commit range" section, add after the existing description:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
|
||||||
|
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
|
||||||
|
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
|
||||||
|
The handler never links to itself.
|
||||||
|
```
|
||||||
|
|
||||||
|
Bump that note's "verified against" commit line to the current HEAD.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
|
||||||
|
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Report the visual-verification gap**
|
||||||
|
|
||||||
|
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
|
||||||
|
|
||||||
|
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
|
||||||
|
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
|
||||||
|
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
|||||||
|
# Feedback für langlaufende Operationen — Vorgaben für die Umsetzung
|
||||||
|
|
||||||
|
> **Dieses Dokument ist absichtlich kein Task-Skript.** Es enthält die Vorgaben pro Gruppe.
|
||||||
|
> Die konkreten Tasks schreibt der **Merge-Helper je Gruppe** beim Ausführen — er kennt dann
|
||||||
|
> den tatsächlichen Codestand. Was hier steht, ist bindend; was hier nicht steht, entscheidet
|
||||||
|
> er. Checkboxen stehen auf Paketebene, damit der Fortschritt sichtbar bleibt.
|
||||||
|
|
||||||
|
**Design:** `docs/superpowers/specs/2026-08-11-operation-feedback-design.md` — dort stehen die
|
||||||
|
Belege (Datei:Zeile) und die Begründungen. Dieses Dokument wiederholt sie nicht.
|
||||||
|
|
||||||
|
**Ziel:** Jede Operation, die länger als ~300 ms dauern kann, zeigt an, dass sie läuft, was sie
|
||||||
|
tut und wie lange sie schon läuft — über **einen** Mechanismus statt pro Fall neu gebaut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ausführungsmodell
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 (Fundament) ── muss ALLEIN und ZUERST landen
|
||||||
|
│
|
||||||
|
├── Gruppe A UI-Stille Merge-Helper 1
|
||||||
|
├── Gruppe B UI-Freeze Merge-Helper 2
|
||||||
|
└── Gruppe C Worker-Stille Merge-Helper 3
|
||||||
|
|
||||||
|
Gruppe D MCP-Stille Merge-Helper 4 ── unabhängig von P0, kann sofort starten
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ein Merge-Helper pro Gruppe.** Nach P0 laufen A, B, C und D parallel. Jede Gruppe hält sich
|
||||||
|
strikt an ihr Datei-Eigentum — das ist die einzige Absicherung gegen gegenseitiges
|
||||||
|
Überschreiben, weil alle im gemeinsamen `main`-Checkout arbeiten.
|
||||||
|
|
||||||
|
### Datei-Eigentum (bindend)
|
||||||
|
|
||||||
|
| Gruppe | Besitzt exklusiv | Darf **nicht** anfassen |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0** | `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), **beide `locales/*.json`**, `Ui/Services/WorkerClient.cs` (nur Timing-Hook) | alles andere |
|
||||||
|
| **A** | Island-VMs, Modal-VMs, deren AXAML, `Ui/Services/UpdateCheckService.cs` | `locales/*`, `WorkerClient`, `IWorkerClient`, Worker-Projekt |
|
||||||
|
| **B** | `Ui/ViewModels/Modals/DiffViewerViewModel.cs`, `Ui/Views/Controls/DiffTextView.axaml.cs` | `locales/*`, Island-VMs, Worker-Projekt |
|
||||||
|
| **C** | `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `StubWorkerClient.cs`, `Worker/Lifecycle/*Recovery.cs`, `Worker/Runner/WorktreeManager.cs`, `Worker/Worktrees/WorktreeMaintenanceService.cs` | `locales/*`, Island-VMs, `Worker/External/*` |
|
||||||
|
| **D** | `Worker/External/*McpTools.cs`, `Worker/External/ExternalMcpService.cs`, `Worker/Lifecycle/TaskMergeService.cs` (nur die Progress-Schleife) | `locales/*`, alles im Ui-Projekt |
|
||||||
|
|
||||||
|
**Locale-Regel:** Nur P0 schreibt in `en.json`/`de.json`. Braucht eine Gruppe doch einen Key,
|
||||||
|
den P0 nicht vorgesehen hat: als **letzte** Änderung des Pakets anhängen und im Merge-Helper
|
||||||
|
als bekannte Konfliktstelle behandeln. Nie mitten in der Gruppe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorgaben für den Merge-Helper selbst
|
||||||
|
|
||||||
|
Gilt für alle vier Gruppen:
|
||||||
|
|
||||||
|
- **Agent-Modell:** `sonnet` für Implementierer und Reviewer. Nie haiku, nie opus, nie Fable.
|
||||||
|
- **`maxTurns` explizit auf 200 setzen.** `model_presets` ist NULL, sonst bekommt ein
|
||||||
|
sonnet-Task 30 Turns und stirbt mit „exited with code 1 and no result".
|
||||||
|
- **`serializeOnFileOverlap` auf der Gruppenliste einschalten.** Innerhalb einer Gruppe
|
||||||
|
fassen mehrere Pakete dieselben Dateien an.
|
||||||
|
- **Bauen:** `dotnet build ClaudeDo.slnx` schlägt auf .NET 8 fehl. Einzelprojekte mit
|
||||||
|
`-c Release` bauen (ein laufender Worker sperrt `Debug`):
|
||||||
|
```
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
```
|
||||||
|
- **Testen — pro Gruppe die relevanten Projekte:**
|
||||||
|
```
|
||||||
|
A, B dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/... -c Release (nur wenn Keys berührt)
|
||||||
|
C Ui.Tests + ClaudeDo.Worker.Tests (Worker.Tests enthält auch UiVm-Tests)
|
||||||
|
D dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
P0 Ui.Tests + Localization.Tests
|
||||||
|
```
|
||||||
|
Fällt ein Test innerhalb der Verify-Kette, erst die Suite **allein** laufen lassen — die
|
||||||
|
`Ui.Tests` haben eine bekannte reihenfolgen-abhängige Flakiness, die nichts mit dem Merge zu
|
||||||
|
tun hat.
|
||||||
|
- **Nie `git add -A`.** Immer explizit nach Pfad stagen und `git commit -- <pfade>` — der
|
||||||
|
`main`-Checkout ist von parallelen Sessions geteilt, ein blankes Commit fegt fremde WIP mit.
|
||||||
|
- **Nach dem Batch-Merge `main` selbst prüfen.** Approve baut und testet nicht. Neun grüne
|
||||||
|
Branches haben `main` schon zweimal zerlegt, davon einmal durch eine Test-Kollision, die ein
|
||||||
|
reiner src-Build nicht sieht. Also: Build **und** die betroffenen Testprojekte.
|
||||||
|
- **Vor Approve prüfen, ob der Branch überhaupt etwas enthält** (`changedFileCount`). Ein
|
||||||
|
blockierter Task wird sonst `Done` mit leerem Branch.
|
||||||
|
- **Keine EF-Migration** in diesem Vorhaben. Parallele Migrationen aus Geschwister-Branches
|
||||||
|
löschen sich beim SQLite-Table-Rebuild gegenseitig die Spalten, und die Tests bemerken es
|
||||||
|
nicht (`EnsureCreated`).
|
||||||
|
- **Keine Tests, die die echte `claude`-CLI starten.**
|
||||||
|
- **Visuelle Prüfung ist nicht durch Tests ersetzbar.** Jedes Paket in A und B endet mit einer
|
||||||
|
offenen visuellen Prüfung für den Nutzer. Nie behaupten, die UI funktioniere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Fundament
|
||||||
|
|
||||||
|
- [x] **P0-1 — `OperationStatus` + `OperationIndicator` + Locale-Keys**
|
||||||
|
- [x] **P0-2 — Timing-Hook für die Messung**
|
||||||
|
- [x] **P0-3 — Regel in den CLAUDE.md-Dateien**
|
||||||
|
|
||||||
|
> **Stand 2026-08-17.** P0, A (außer A5), B, C1 und D sind gemergt; der visuelle Pass über A und B
|
||||||
|
> ist durch, ohne Fund. Nachtrag zu P0-2: der Timing-Sink liegt seit dem Release-Vorlauf hinter
|
||||||
|
> `CLAUDEDO_OP_TIMING=1` und ist **standardmäßig aus** — die 63 `InvokeTimedAsync`-Call-Sites
|
||||||
|
> bleiben bewusst stehen. Offen: C2–C5.
|
||||||
|
|
||||||
|
**Vorbedingung:** keine. Muss allein landen, bevor A/B/C starten.
|
||||||
|
|
||||||
|
### Der Vertrag (bindend — 19 Pakete hängen daran)
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Services/OperationStatus.cs`, eine `ObservableObject`-Klasse:
|
||||||
|
|
||||||
|
| Member | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `bool IsRunning` | **sofort** `true` bei `Begin`. Treibt `CanExecute`, verhindert Doppelklick |
|
||||||
|
| `bool ShowIndicator` | erst nach **300 ms** `true`. Kein Flackern bei schnellen Calls |
|
||||||
|
| `string? Label` | lokalisierter Text |
|
||||||
|
| `string Elapsed` | `mm:ss`, **lokal getickt** |
|
||||||
|
| `bool IsStalled` | `true` nach **60 s ohne `Report`** |
|
||||||
|
| `IDisposable Begin(string label)` | startet; `Dispose` beendet **auch im Exception-Fall** |
|
||||||
|
| `void Report(string label)` | überschreibt `Label` mid-flight, setzt die Stall-Uhr zurück |
|
||||||
|
|
||||||
|
**Nachbesserung 1 (bindend):** `IsStalled` bemisst sich an der Zeit **seit dem letzten
|
||||||
|
`Report`**, nicht an der Gesamtdauer. Ein regulär mehrminütiges Verify-Gate darf sich nicht
|
||||||
|
selbst als hängend melden. Eine Operation ohne jeden `Report` gilt nach 60 s als stalled — das
|
||||||
|
ist genau der Fall, der heute wie ein Absturz aussieht.
|
||||||
|
|
||||||
|
Benutzung im ViewModel:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
|
||||||
|
var result = await _worker.ApproveReviewAsync(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Weitere Vorgaben
|
||||||
|
|
||||||
|
- **Zeitquelle injizierbar** über `TimeProvider` (in .NET 8 vorhanden). **Kein statischer
|
||||||
|
`DispatcherTimer`** — geteilter statischer State ist die Ursache der reihenfolgen-abhängigen
|
||||||
|
Flakiness in den `Ui.Tests`. Timer-Callbacks kommen vom Threadpool und müssen auf den
|
||||||
|
Dispatcher gepostet werden.
|
||||||
|
- **Mehrere `OperationStatus` pro VM sind erwünscht.** `WorktreesOverviewModalViewModel`
|
||||||
|
braucht getrennte für Refresh, Cleanup und Merge — sonst blockiert ein laufender Refresh die
|
||||||
|
Cleanup-Anzeige.
|
||||||
|
- **`OperationStatus` transportiert keine Fehler.** Fehlerbehandlung bleibt unverändert über
|
||||||
|
`ShowErrorAsync` / `ErrorReported` / `FlashFooterError`.
|
||||||
|
- **`OperationIndicator`** nutzt `Ellipse.spinner` aus `Design/IslandStyles.axaml` (14×14,
|
||||||
|
Accent, 0.9 s Rotation) plus Label und Elapsed. Ohne dieses Control wird die
|
||||||
|
Spinner-StackPanel aus `MergeModalView.axaml` acht Mal von Hand nachgebaut. Werte aus
|
||||||
|
`Tokens.axaml` verwenden, keine Inline-Zahlen.
|
||||||
|
- **Locale-Keys:** neuer Top-Level-Namespace `ops` in `en.json` und `de.json`, Parität wird von
|
||||||
|
`Localization.Tests` erzwungen. P0 legt die Keys für **A und C** vorab an — abgeleitet aus den
|
||||||
|
Paketlisten unten.
|
||||||
|
|
||||||
|
**Nachbesserung 3 (bindend):** **Gruppe D braucht keine Locale-Keys.**
|
||||||
|
MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
|
||||||
|
Klartext-Strings.
|
||||||
|
|
||||||
|
### P0-2: Messung an genau einer Stelle
|
||||||
|
|
||||||
|
Zwei Hooks, nicht zwanzig:
|
||||||
|
1. In `WorkerClient` jeden Hub-Invoke mit Dauer loggen.
|
||||||
|
2. Im DB-Pfad der Islands dasselbe.
|
||||||
|
|
||||||
|
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. **A5 und C werten sie aus**,
|
||||||
|
statt zu raten. Bild 2 und 4 brauchen keine Messung.
|
||||||
|
|
||||||
|
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
|
||||||
|
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
|
||||||
|
|
||||||
|
### Definition of Done für P0
|
||||||
|
|
||||||
|
`OperationStatus` hat Unit-Tests mit einem Fake-`TimeProvider` für: 300-ms-Grace, 60-s-Stall
|
||||||
|
**ab letztem Report**, Elapsed-Formatierung, `Dispose` nach Exception, `Report` überschreibt
|
||||||
|
Label und setzt die Stall-Uhr zurück. `Localization.Tests` grün. Kein VM ist umgestellt — P0
|
||||||
|
liefert nur das Werkzeug.
|
||||||
|
|
||||||
|
### Task-Entwürfe (grob)
|
||||||
|
|
||||||
|
**P0-1 · `OperationStatus` + `OperationIndicator` + `ops`-Locale-Keys**
|
||||||
|
Baue das Primitive nach dem Vertrag oben, dazu das Anzeige-Control und den neuen
|
||||||
|
Locale-Namespace `ops` in beiden Sprachdateien. Keys für Gruppe A und C vorab anlegen, für D
|
||||||
|
keine. Zeitquelle über `TimeProvider`, kein statischer Timer.
|
||||||
|
*Dateien:* `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), `Localization/locales/{en,de}.json`, `tests/ClaudeDo.Ui.Tests/`
|
||||||
|
*Fertig wenn:* Unit-Tests für Grace, Stall-ab-Report, Elapsed, Dispose-nach-Exception, Report-Reset. Localization.Tests grün. Kein ViewModel angefasst.
|
||||||
|
|
||||||
|
**P0-2 · Timing-Hook für die Messung**
|
||||||
|
Zwei Hooks: Dauer jedes Hub-Invokes in `WorkerClient`, Dauer des DB-Pfads der Islands. Ziel
|
||||||
|
sind auswertbare Zahlen, keine Anzeige. Ausgabe so, dass sie nach einem Tag Nutzung sortierbar
|
||||||
|
ist.
|
||||||
|
*Dateien:* `Ui/Services/WorkerClient.cs`, der gemeinsame DB-Zugriffspfad der Island-VMs
|
||||||
|
*Fertig wenn:* Beide Hooks aktiv, Overhead vernachlässigbar, das Format ist dokumentiert.
|
||||||
|
|
||||||
|
**P0-3 · Feedback-Regel in den CLAUDE.md-Dateien**
|
||||||
|
`src/ClaudeDo.Ui/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch
|
||||||
|
eine `OperationStatus`, Anzeige über `OperationIndicator`. `src/ClaudeDo.Worker/CLAUDE.md`: ein
|
||||||
|
MCP-Tool, das über ~5 s laufen kann, reportet Progress.
|
||||||
|
*Fertig wenn:* Beide Regeln stehen, jeweils mit einem Satz Begründung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe A — Bild 1: UI-Stille
|
||||||
|
|
||||||
|
- [x] **A1 — Detail-Pane** ⚠️ *der gemeldete Fall*
|
||||||
|
- [x] **A2 — WorktreesOverview**
|
||||||
|
- [x] **A3 — Settings-Tabs** — ohne OnlineInbox-SignIn, siehe Nachzügler unten
|
||||||
|
- [x] **A4 — Reports und Planning-UI** — ohne die beiden Planning-Calls im `DiffViewerViewModel`
|
||||||
|
- [x] ~~**A5 — Island-DB-Pfade nach Messdaten**~~ — **abgesagt** (2026-08-13): kein DB-Pfad über
|
||||||
|
500 ms (höchster p95 372 ms), die Ausreißer saßen alle auf der Hub-Seite. Wie vorgesehen
|
||||||
|
geschlossen, ohne Code.
|
||||||
|
|
||||||
|
**Nachzügler (kein Task):** drei Locale-Keys aus P0 sind ungenutzt geblieben —
|
||||||
|
`ops.onlineInbox.signingIn` (A3), `ops.planning.buildingIntegrationBranch` und
|
||||||
|
`ops.planning.loadingAggregate` (A4). Die beiden Planning-Calls liegen in
|
||||||
|
`DiffViewerViewModel.cs`, einer Datei aus Gruppe B, die A4 nicht anfassen durfte.
|
||||||
|
|
||||||
|
**Vorbedingung:** P0 gemergt.
|
||||||
|
|
||||||
|
### Vorgaben
|
||||||
|
|
||||||
|
- **A1 zuerst.** `DetailsIslandViewModel.ApproveReviewAsync` ist der gemeldete Schmerz. Dazu im
|
||||||
|
selben Paket: `SubmitForReviewAsync`, `RejectReviewAsync`, `ParkReviewAsync` und
|
||||||
|
`MergeSectionViewModel.PreviewMergeAsync`.
|
||||||
|
- **A1 abonniert `IWorkerClient.MergeProgressEvent`**, um das Label mid-flight von „merging" auf
|
||||||
|
„verifying" zu schärfen. Das Event existiert seit `cad0582`.
|
||||||
|
|
||||||
|
**Nachbesserung 2 (bindend):** A verlässt sich darauf, dass dieses Event **bestehen bleibt**.
|
||||||
|
C1 baut den generischen Kanal darunter, muss `MergeProgressEvent` aber als dünnen Forwarder
|
||||||
|
erhalten. Andernfalls müsste C `DetailsIslandViewModel` ändern — eine Datei aus Gruppe A —
|
||||||
|
und die Parallelität wäre zerstört. **A darf keinen anderen Kanal verwenden.**
|
||||||
|
- **Abo nur für die Dauer des Calls.** Der Worker broadcastet an alle Clients; ein dauerhaft
|
||||||
|
registriertes VM bekommt Events für fremde Tasks. `MergeModalViewModel` macht es richtig
|
||||||
|
vor: `+=` im `try`, `-=` im `finally`, plus `if (taskId != TaskId) return;`.
|
||||||
|
- **A2:** `IsBusy` existiert dort schon, schaltet aber nur `IsEnabled`. Die Anzeige fehlt
|
||||||
|
komplett. Betrifft Refresh, Cleanup, Reset, ForceRemove und Batch-Merge — Batch-Merge
|
||||||
|
zusätzlich mit Zeilen-Status, weil dort N Tasks sequenziell durchlaufen.
|
||||||
|
- **A3:** SessionSkill-Install ist ein `git clone` — der offensichtlichste Kandidat. Dazu
|
||||||
|
Update/Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check.
|
||||||
|
- **A4:** GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch,
|
||||||
|
GetPlanningAggregate, Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined`
|
||||||
|
existiert bereits und bleibt — nicht doppeln.
|
||||||
|
- **A5 erst nach Auswertung von P0-2.** Nur Stellen umstellen, die die Messung als Ausreißer
|
||||||
|
zeigt. Kein Umstellen auf Verdacht.
|
||||||
|
- **`DetailsIslandViewModel` ist groß.** Nicht umstrukturieren, nur die Commands anfassen.
|
||||||
|
- **Bereits saubere Flächen nicht anfassen:** `MergeModalViewModel`,
|
||||||
|
`ConflictResolverViewModel`, `DiffViewerViewModel.IsLoadingCombined`,
|
||||||
|
`UsageMonitorModalViewModel`.
|
||||||
|
|
||||||
|
### Definition of Done pro Paket
|
||||||
|
|
||||||
|
Jeder umgestellte Command: `IsRunning` während des Laufs gesetzt, `CanExecute` gesperrt,
|
||||||
|
Zustand nach einer Exception zurückgesetzt — als Test in `ClaudeDo.Ui.Tests`. Kein
|
||||||
|
handgebauter Spinner, immer `OperationIndicator`. **Visuelle Prüfung offen und explizit
|
||||||
|
benannt** (Grace-Periode und Layout kann kein Test bestätigen).
|
||||||
|
|
||||||
|
### Task-Entwürfe (grob)
|
||||||
|
|
||||||
|
**A1 · Detail-Pane: Approve, Submit, Reject, Park, Preview** ⚠️ *der gemeldete Fall*
|
||||||
|
Fünf Commands im Detail-Pane bekommen je eine `OperationStatus` und einen `OperationIndicator`
|
||||||
|
neben dem auslösenden Button. Approve abonniert zusätzlich `MergeProgressEvent` **nur für die
|
||||||
|
Dauer des Calls** und filtert auf die eigene TaskId, um das Label von „merging" auf „verifying"
|
||||||
|
zu schärfen. `DetailsIslandViewModel` nicht umstrukturieren.
|
||||||
|
*Dateien:* `Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `MergeSectionViewModel.cs`, `Ui/Views/Islands/Detail/*`
|
||||||
|
*Fertig wenn:* Approve zeigt während des Verify-Gates Phase und Elapsed, Button ist gesperrt, Zustand nach Fehler zurückgesetzt. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**A2 · WorktreesOverview: Anzeige für alle fünf Aktionen**
|
||||||
|
`IsBusy` existiert, schaltet aber nur `IsEnabled`. Getrennte `OperationStatus` für Refresh,
|
||||||
|
Cleanup, Reset, ForceRemove und Batch-Merge — der Batch-Merge zusätzlich mit Zeilen-Status, weil
|
||||||
|
er N Tasks sequenziell durchläuft.
|
||||||
|
*Dateien:* `Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`, `Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||||
|
*Fertig wenn:* Jede der fünf Aktionen zeigt sichtbar, dass sie läuft; ein laufender Refresh blockiert die Cleanup-Anzeige nicht. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**A3 · Settings-Tabs: Skill-Install, Restore-Defaults, OnlineInbox, RepoImport, Update-Check**
|
||||||
|
Fünf Flächen, bei denen `IsBusy` heute nur den Button sperrt. Der Skill-Install ist ein
|
||||||
|
`git clone` und der offensichtlichste Kandidat.
|
||||||
|
*Dateien:* `Ui/ViewModels/Modals/Settings/*`, `Ui/ViewModels/Modals/RepoImportModalViewModel.cs`, `Ui/Services/UpdateCheckService.cs`, `Ui/Views/Modals/SettingsModalView.axaml`
|
||||||
|
*Fertig wenn:* Alle fünf zeigen Aktivität; der Skill-Install zeigt zusätzlich, dass geklont wird. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**A4 · Reports und Planning-UI**
|
||||||
|
GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate,
|
||||||
|
Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined` existiert bereits und
|
||||||
|
bleibt unangetastet.
|
||||||
|
*Dateien:* `Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs`, Planning-Pfade in `DetailsIslandViewModel`/`TasksIslandViewModel`, zugehörige AXAML
|
||||||
|
*Fertig wenn:* Report-Generierung und Planning-Integration zeigen Aktivität; kein Doppel-Indikator im Combined-Diff. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**A5 · Island-DB-Pfade nach Messdaten**
|
||||||
|
**Erst die Zahlen aus P0-2 auswerten**, dann nur die Ausreißer umstellen. Kandidaten:
|
||||||
|
`ClearCompleted`, Drag-Reorder über viele Zeilen, Laden großer Listen. Nichts auf Verdacht.
|
||||||
|
*Dateien:* nach Messergebnis
|
||||||
|
*Fertig wenn:* Die Auswertung ist im Task dokumentiert, und jede umgestellte Stelle ist durch eine Messung begründet. Wenn nichts über der Schwelle liegt: Task mit Begründung schließen, nichts bauen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe B — Bild 2: UI-Freeze
|
||||||
|
|
||||||
|
- [x] **B1 — `UnifiedDiffParser.Parse` auslagern**
|
||||||
|
- [x] **B2 — `DiffAlignment.Build` Grenze**
|
||||||
|
- [x] **B3 — Guard-Test gegen Dispatcher-Blockade**
|
||||||
|
|
||||||
|
**Vorbedingung:** P0 gemergt (für den Indikator in B1).
|
||||||
|
|
||||||
|
### Vorgaben
|
||||||
|
|
||||||
|
- **Reihenfolge ist zwingend: erst auslagern, dann anzeigen.** Ein Spinner auf einem
|
||||||
|
blockierten UI-Thread wird nicht gezeichnet. B1 verschiebt `UnifiedDiffParser.Parse` nach
|
||||||
|
`Task.Run` und setzt danach den Indikator.
|
||||||
|
- **`UnifiedDiffParser` ist statisch und rein** — thread-safe, `Task.Run` ist unkritisch. Das
|
||||||
|
Zurückschreiben der Ergebnisse in Observable-Collections muss auf dem UI-Thread passieren.
|
||||||
|
- **B2 braucht eine Entscheidung, die der Merge-Helper trifft:** `DiffAlignment.Build` läuft in
|
||||||
|
`DiffTextView.axaml.cs` — in einem Control, nicht in einem VM. Entweder Grenze („ab N Zeilen
|
||||||
|
auslagern") oder inkrementeller Aufbau. Die Wahl gehört ins Paket, nicht hierher; die
|
||||||
|
Vorgabe ist nur: **eine sichtbare Grenze definieren, keine unbegrenzte Synchron-Arbeit.**
|
||||||
|
- **AvaloniaEdit-Fallen** (belegt, nicht neu ausprobieren): `this.TryGetResource` in einem
|
||||||
|
Control findet Brushes aus `Tokens.axaml` **nie** und scheitert lautlos; Brushes und Typeface
|
||||||
|
nicht im Konstruktor auflösen. `ScrollToVerticalOffset` ist in v12 ein No-op — schreiben über
|
||||||
|
`ScrollViewer.Offset`, lesen über `TextView.ScrollOffsetChanged`.
|
||||||
|
- Gemeinsames Editor-Boilerplate gehört in `Views/Controls/DiffEditorSetup.cs`, nicht ein
|
||||||
|
drittes Mal kopiert.
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
|
||||||
|
B3 ist der einzige Test im Vorhaben, der Blockade prüft: großes Diff-Fixture, Nachweis, dass
|
||||||
|
der Dispatcher weiter Nachrichten verarbeitet. Visuelle Prüfung offen (Verhalten bei großem
|
||||||
|
Diff, Indikator während des Parsens).
|
||||||
|
|
||||||
|
### Task-Entwürfe (grob)
|
||||||
|
|
||||||
|
**B1 · `UnifiedDiffParser.Parse` vom UI-Thread nehmen**
|
||||||
|
Beide Aufrufstellen im `DiffViewerViewModel` nach `Task.Run` verschieben, danach den
|
||||||
|
`OperationIndicator` setzen. Reihenfolge ist zwingend: erst auslagern, dann anzeigen — ein
|
||||||
|
Spinner auf einem blockierten Dispatcher wird nicht gezeichnet. Das Zurückschreiben in
|
||||||
|
Observable-Collections muss auf dem UI-Thread passieren.
|
||||||
|
*Dateien:* `Ui/ViewModels/Modals/DiffViewerViewModel.cs`
|
||||||
|
*Fertig wenn:* Kein synchroner Parse mehr im VM, Indikator während des Parsens sichtbar. Visuelle Prüfung mit einem großen Diff offen.
|
||||||
|
|
||||||
|
**B2 · `DiffAlignment.Build` — Grenze gegen unbegrenzte Synchron-Arbeit**
|
||||||
|
`Build` läuft in einem Control, nicht in einem VM. Der ausführende Agent entscheidet zwischen
|
||||||
|
Auslagern ab einer Zeilenzahl und inkrementellem Aufbau — die Vorgabe ist nur: **eine
|
||||||
|
sichtbare, begründete Grenze**. AvaloniaEdit-Fallen aus dem Vorgaben-Abschnitt beachten
|
||||||
|
(`TryGetResource` scheitert lautlos, `ScrollToVerticalOffset` ist ein No-op).
|
||||||
|
*Dateien:* `Ui/Views/Controls/DiffTextView.axaml.cs`, gemeinsames Boilerplate nach `DiffEditorSetup.cs`
|
||||||
|
*Fertig wenn:* Die gewählte Grenze ist im Code kommentiert und begründet; große Diffs blockieren nicht mehr unbegrenzt. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**B3 · Guard-Test: großer Diff blockiert den Dispatcher nicht**
|
||||||
|
Headless-Test mit großem Diff-Fixture, der nachweist, dass der Dispatcher während Parse und
|
||||||
|
Build weiter Nachrichten verarbeitet. Der einzige Blockade-Test im Vorhaben — soll auch
|
||||||
|
zukünftige Regressionen fangen.
|
||||||
|
*Dateien:* `tests/ClaudeDo.Ui.Tests/`, Fixture im Testprojekt
|
||||||
|
*Fertig wenn:* Der Test fällt gegen den Stand **vor** B1/B2 und ist danach grün.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe C — Bild 3: Worker-Stille
|
||||||
|
|
||||||
|
- [x] **C1 — generischer `OperationProgress`-Kanal**
|
||||||
|
- [ ] **C2 — Startup-Recovery sichtbar**
|
||||||
|
- [ ] **C3 — Worktree-Anlage sichtbar**
|
||||||
|
- [ ] **C4 — Rebase-after-Merge und WorktreeMaintenance**
|
||||||
|
- [ ] **C5 — periodische Dienste**
|
||||||
|
|
||||||
|
> **Einzige offene Gruppe.** C2–C5 sind unblockiert (C1 ist gemergt). Woran man den Reststand
|
||||||
|
> erkennt: die vier Keys `ops.worker.startupRecovery`, `.creatingWorktree`, `.rebasingAfterMerge`
|
||||||
|
> und `.maintainingWorktrees` liegen ungenutzt in beiden Sprachdateien.
|
||||||
|
|
||||||
|
**Vorbedingung:** P0 gemergt. Die Abhängigkeit zur Parallelsession ist mit `cad0582`
|
||||||
|
aufgelöst.
|
||||||
|
|
||||||
|
### Vorgaben
|
||||||
|
|
||||||
|
- **Der Kanal:**
|
||||||
|
```
|
||||||
|
OperationProgress(string opKey, string phase, int current, int total)
|
||||||
|
```
|
||||||
|
`opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
|
||||||
|
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`).
|
||||||
|
- **Kein Elapsed auf der Leitung.** Das UI tickt lokal. Die heutige Implementierung lässt den
|
||||||
|
Worker alle 30 s ticken — damit zeigt sie die ersten 30 Sekunden nur „merging". C1 stellt das
|
||||||
|
um.
|
||||||
|
- **`MergeProgressEvent` bleibt als Forwarder** (siehe Nachbesserung 2 unter Gruppe A). Vier
|
||||||
|
Zeilen. Entfernen erst, wenn A und C beide gemergt sind — nicht in dieser Gruppe.
|
||||||
|
- **`current`/`total` wird als Text gezeigt, nie als Prozentbalken.** Die meisten Operationen
|
||||||
|
haben kein sinnvolles Total.
|
||||||
|
- **C3 zuerst prüfen, nicht bauen:**
|
||||||
|
`docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md` führt
|
||||||
|
`WorktreeManager.cs:103` als DB-Write-ohne-Broadcast. Ob das inzwischen geschlossen ist,
|
||||||
|
gehört geprüft, bevor es doppelt behoben wird.
|
||||||
|
- **C5 ist der wackeligste Punkt im Design.** Periodische Dienste (Usage, OnlineSync, Prime,
|
||||||
|
Queue) dürfen **nur Aktivität und Fehler** melden, keinen Tick-Strom. Wenn der Merge-Helper
|
||||||
|
beim Umsetzen zum Schluss kommt, dass C5 nur Rauschen erzeugt: **weglassen und begründen**,
|
||||||
|
statt es durchzuziehen.
|
||||||
|
- **Test-Fakes wachsen mit.** Ein neues Event auf `IWorkerClient`/`WorkerHub` bricht
|
||||||
|
handgeschriebene Fakes in **beiden** Testprojekten — `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||||
|
und die UiVm-Tests unter `tests/ClaudeDo.Worker.Tests/`.
|
||||||
|
- **Broadcast-Sparsamkeit:** vor einem „fehlenden Broadcast" immer den Aufrufer prüfen. Im
|
||||||
|
08-07-Spec war ein gemeldetes Loch in Wahrheit schon vom Aufrufer abgedeckt, und der Fix
|
||||||
|
wäre ein Duplikat gewesen.
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
|
||||||
|
Progress-Callbacks in `ClaudeDo.Worker.Tests` mit einem Fake gezählt — keine echten Timeouts,
|
||||||
|
keine echte CLI. UI-Seite: die Anzeige beim Worker-Start ersetzt „reconnecting" durch die
|
||||||
|
laufende Recovery-Phase. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
### Task-Entwürfe (grob)
|
||||||
|
|
||||||
|
**C1 · Generischer `OperationProgress`-Kanal, `MergeProgress` darauf umstellen**
|
||||||
|
Neues Hub-Event `OperationProgress(opKey, phase, current, total)`, ohne Elapsed auf der Leitung.
|
||||||
|
Der Merge-Pfad sendet darüber. **`MergeProgressEvent` bleibt als vierzeiliger Forwarder** —
|
||||||
|
Gruppe A hängt daran und darf nicht angefasst werden. Fakes in beiden Testprojekten mitziehen.
|
||||||
|
*Dateien:* `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, UiVm-Fakes in `Worker.Tests`
|
||||||
|
*Fertig wenn:* Merge-Phasen laufen über den neuen Kanal, das UI tickt Elapsed lokal, `MergeProgressEvent` funktioniert unverändert weiter, beide Testprojekte grün.
|
||||||
|
|
||||||
|
**C2 · Worker-Startup-Recovery sichtbar machen**
|
||||||
|
Die sechs Recovery-Services beim Worker-Start melden Phase und `i/n` über den Kanal
|
||||||
|
(`opKey = "startup-recovery"`). Das UI ersetzt „reconnecting" durch die laufende Phase.
|
||||||
|
*Dateien:* `Worker/Lifecycle/*Recovery.cs`, Anzeige im `IslandsShellViewModel`-Umfeld
|
||||||
|
*Fertig wenn:* Beim Worker-Start ist sichtbar, welche Recovery läuft. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**C3 · Worktree-Anlage beim Task-Start sichtbar machen**
|
||||||
|
**Zuerst prüfen**, ob der im 08-07-Spec gemeldete Broadcast-Gap in `WorktreeManager` inzwischen
|
||||||
|
geschlossen ist — nicht doppelt beheben. Danach die stille Lücke zwischen `Queued` und erster
|
||||||
|
Ausgabe an der Task-Zeile sichtbar machen.
|
||||||
|
*Dateien:* `Worker/Runner/WorktreeManager.cs`, Task-Row-Anzeige
|
||||||
|
*Fertig wenn:* Das Prüfergebnis steht im Task; die Anlage-Phase ist an der Zeile sichtbar. Visuelle Prüfung offen.
|
||||||
|
|
||||||
|
**C4 · Rebase-after-Merge und WorktreeMaintenance in den Kanal**
|
||||||
|
`RebaseOthersAfterMergeAsync` läuft heute innerhalb des Merge-Calls **nach** dem
|
||||||
|
Phasen-Broadcast und ist damit vollständig unsichtbar. Dazu der Hintergrunddienst über alle
|
||||||
|
Worktrees, mit `i/n`.
|
||||||
|
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs` (Rebase-Abschnitt), `Worker/Worktrees/WorktreeMaintenanceService.cs`
|
||||||
|
*Fertig wenn:* Beide melden Fortschritt; ein Merge zeigt nach dem Verify-Gate die Rebase-Phase statt Stille.
|
||||||
|
|
||||||
|
**C5 · Periodische Dienste — optional, Abbruch erlaubt**
|
||||||
|
Usage, OnlineSync, Prime, Queue sollen **nur Aktivität und Fehler** melden, keinen Tick-Strom.
|
||||||
|
Der wackeligste Punkt im Design: kommt der ausführende Agent zum Schluss, dass das nur
|
||||||
|
Anzeige-Rauschen erzeugt, **weglassen und begründen** statt durchziehen.
|
||||||
|
*Dateien:* `Worker/Usage/`, `Worker/Online/`, `Worker/Prime/`, `Worker/Queue/`
|
||||||
|
*Fertig wenn:* Entweder sparsame Meldungen ohne Rauschen — oder eine begründete Absage im Task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gruppe D — Bild 4: MCP-Stille
|
||||||
|
|
||||||
|
- [x] **D1 — `ProgressReporter` extrahieren**
|
||||||
|
- [x] **D2 — die `batch_*`-Tools** — es sind **sieben**, nicht acht (die Achterzahl unten war ein
|
||||||
|
Zählfehler beim Schreiben des Plans)
|
||||||
|
- [x] **D3 — Worktree- und Diff-Tools** — drei davon; `batch_cleanup_task_worktrees` lief über D2
|
||||||
|
- [x] **D4 — Rest und Doku-Regel** — fand zwei echte Lücken, die D1–D3 offen gelassen hatten:
|
||||||
|
`continue_merge` reichte sein Progress-Token nicht ins Post-Merge-Verify-Gate, und der
|
||||||
|
Unit-Merge-Drain im `PlanningMergeOrchestrator` verlor es nach dem ersten Tick. Dazu
|
||||||
|
`list_worktrees`.
|
||||||
|
|
||||||
|
**Vorbedingung:** keine. Kann sofort starten, parallel zu P0.
|
||||||
|
|
||||||
|
### Vorgaben
|
||||||
|
|
||||||
|
- **Warum das zählt:** ohne Progress bricht der MCP-Client bei Stille nach 300 s ab, während
|
||||||
|
der Worker weiterarbeitet. Das ist die dokumentierte Ursache dafür, dass ein Merge zwar
|
||||||
|
durchläuft, den Task aber nie auf `Done` bringt und Abhängige dauerhaft blockiert.
|
||||||
|
- **D1:** `TaskMergeService.RunReportingProgressAsync` ist die einzige existierende
|
||||||
|
Progress-Schleife. Als eigenständige Klasse extrahieren, plus ein Overload für
|
||||||
|
Element-Fortschritt (`i/n`). Nicht kopieren — extrahieren, damit es eine Implementierung
|
||||||
|
bleibt.
|
||||||
|
- **`TaskMergeService` ist die einzige Datei, die D mit C teilt.** D fasst dort ausschließlich
|
||||||
|
die Progress-Schleife an. Wenn beide Gruppen gleichzeitig laufen, ist das die Stelle, die der
|
||||||
|
Merge-Helper im Auge behalten muss.
|
||||||
|
- **Keine Locale-Keys** (Nachbesserung 3). Englische Klartext-Strings.
|
||||||
|
- **Zielumfang:** die 7 `batch_*`-Tools mit `i/n`, dann `cleanup_task_worktree`,
|
||||||
|
`batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`. Bereits versorgt und
|
||||||
|
nicht anzufassen: die 5 Tools in `ExternalMcpService` mit `IProgress`, `TaskWaitMcpTools`,
|
||||||
|
`merge_task`/`review_task`.
|
||||||
|
- **D4 schreibt die Regel ins Worker-`CLAUDE.md`:** ein MCP-Tool, das über ~5 s laufen kann,
|
||||||
|
reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten Tool.
|
||||||
|
|
||||||
|
### Definition of Done
|
||||||
|
|
||||||
|
Für jedes umgestellte Tool ein Test in `ClaudeDo.Worker.Tests`, der mit einem Fake-`IProgress`
|
||||||
|
zählt, dass Meldungen kommen — bei `batch_*` mindestens eine pro Element. Keine visuelle
|
||||||
|
Prüfung nötig, diese Gruppe hat keine UI.
|
||||||
|
|
||||||
|
### Task-Entwürfe (grob)
|
||||||
|
|
||||||
|
**D1 · `ProgressReporter` extrahieren**
|
||||||
|
`TaskMergeService.RunReportingProgressAsync` als eigenständige Klasse herauslösen, plus einen
|
||||||
|
Overload für Element-Fortschritt (`i/n`). **Extrahieren, nicht kopieren** — es soll eine
|
||||||
|
Implementierung bleiben. Dies ist die einzige Datei, die D mit C teilt; nur die Progress-Schleife
|
||||||
|
anfassen.
|
||||||
|
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs`, neue Klasse im Worker-Projekt
|
||||||
|
*Fertig wenn:* Der Merge-Pfad nutzt die extrahierte Klasse, Verhalten unverändert, bestehende Merge-Tests grün.
|
||||||
|
|
||||||
|
**D2 · Die sieben `batch_*`-Tools mit Element-Fortschritt**
|
||||||
|
Jedes `batch_*`-Tool reportet pro verarbeitetem Element. Grund: ohne Meldung bricht der
|
||||||
|
MCP-Client nach 300 s Stille ab, während der Worker weiterläuft — genau der Mechanismus, der
|
||||||
|
einen Merge durchlaufen lässt, den Task aber nie auf `Done` bringt.
|
||||||
|
*Dateien:* `Worker/External/BatchMcpTools.cs`
|
||||||
|
*Fertig wenn:* Pro Tool ein Test mit Fake-`IProgress`, der mindestens eine Meldung je Element zählt. Englische Klartext-Strings, keine Locale-Keys.
|
||||||
|
|
||||||
|
**D3 · Worktree- und Diff-Tools mit Progress**
|
||||||
|
`cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`.
|
||||||
|
Bereits versorgt und **nicht** anzufassen: die fünf Tools mit `IProgress` in
|
||||||
|
`ExternalMcpService`, `TaskWaitMcpTools`, `merge_task`, `review_task`.
|
||||||
|
*Dateien:* die betroffenen `Worker/External/*McpTools.cs`, `ExternalMcpService.cs`
|
||||||
|
*Fertig wenn:* Jedes der vier Tools meldet Fortschritt, mit Test.
|
||||||
|
|
||||||
|
**D4 · Restliche Long-Runner und die Regel im Worker-`CLAUDE.md`**
|
||||||
|
Verbleibende Tools durchgehen, die über ~5 s laufen können, und die Regel festschreiben: ein
|
||||||
|
MCP-Tool über ~5 s reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten
|
||||||
|
Tool.
|
||||||
|
*Dateien:* restliche `Worker/External/*McpTools.cs`, `src/ClaudeDo.Worker/CLAUDE.md`
|
||||||
|
*Fertig wenn:* Die Regel steht mit Begründung; die durchgegangenen Tools sind im Task aufgelistet — auch die, die bewusst nichts bekommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was nach allen vier Gruppen offen bleibt
|
||||||
|
|
||||||
|
- ~~**Visuelle Prüfung** für alle Pakete in A und B~~ — **am 2026-08-17 durch**, ohne Fund
|
||||||
|
(Worktrees-Übersicht, Settings/Skill-Install, WeeklyReport, großer Diff, Detail-Pane-Approve).
|
||||||
|
- ~~**Auswertung von P0-2**~~ — am 2026-08-13 ausgewertet, siehe A5.
|
||||||
|
- **`MergeProgressEvent`-Forwarder entfernen**, sobald A und C beide gemergt sind. Ein
|
||||||
|
Aufräum-Task, kein Paket.
|
||||||
|
- **Bewusst nicht gebaut:** Cancel, Footer-Anzeige für laufende Operationen, Prozentbalken,
|
||||||
|
Änderungen am Installer (der hat bereits eine vollständige Progress-Pipeline).
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Diff Viewer: Side-by-Side, Syntax Highlighting, Word Diff
|
||||||
|
|
||||||
|
Date: 2026-08-07
|
||||||
|
Status: approved (design), not implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The diff viewer renders every change as a flat unified stream. Reading what actually changed
|
||||||
|
inside a modified line means mentally aligning a `−` row with a `+` row several rows below it.
|
||||||
|
The user reads diffs faster side by side.
|
||||||
|
|
||||||
|
Three gaps, all in the same surface:
|
||||||
|
|
||||||
|
1. No side-by-side mode.
|
||||||
|
2. No syntax highlighting — the merge editor (`ConflictResolverView`) already has it via
|
||||||
|
TextMate, the diff viewer does not.
|
||||||
|
3. No intra-line (word) highlighting, so a one-character change looks like a whole-line rewrite.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
| Concern | Where |
|
||||||
|
|---|---|
|
||||||
|
| Parsing | `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs` → `DiffFileViewModel.Lines` |
|
||||||
|
| Models | `src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs` (`DiffLineViewModel{Kind,OldNo,NewNo,Text}`, `DiffLineKind{Add,Del,Ctx,File}`) |
|
||||||
|
| Rendering | `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` — non-virtualized `ItemsControl`, one `Border`+`Grid`+4 `TextBlock`s per line, `TextWrapping="NoWrap"` |
|
||||||
|
| Host | `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` — Files mode (line 154, `SelectedFile.Lines`) and Planning mode (line 162, flattened `DiffLines` across all files) |
|
||||||
|
| Highlighting reference | `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:80-83` — `RegistryOptions(ThemeName.DarkPlus)` + `InstallTextMate` + `SetGrammar` by file extension |
|
||||||
|
|
||||||
|
`Avalonia.AvaloniaEdit`, `AvaloniaEdit.TextMate` and `TextMateSharp.Grammars` are already
|
||||||
|
referenced in `ClaudeDo.Ui.csproj`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace `DiffLinesView` with an AvaloniaEdit-based control. TextMate highlighting is bound to
|
||||||
|
the `TextEditor` control; it cannot be lifted into `TextBlock` inlines without reimplementing
|
||||||
|
the scope→brush layer that `AvaloniaEdit.TextMate` already provides. Building split/wrap/word
|
||||||
|
diff on the `TextBlock` model first and swapping the renderer later would be throwaway work.
|
||||||
|
|
||||||
|
Side effect worth having: AvaloniaEdit virtualizes, which removes the current non-virtualized
|
||||||
|
`ItemsControl` as a scaling limit on large diffs.
|
||||||
|
|
||||||
|
Rejected: keeping the `ItemsControl` and hand-rolling highlighting from `TextMateSharp`
|
||||||
|
tokenization — same output, materially more code, and a second highlighting path to maintain
|
||||||
|
alongside the merge editor's.
|
||||||
|
|
||||||
|
## Layout semantics
|
||||||
|
|
||||||
|
Left pane is the **old** state, right pane is the **new** state — each side carries the complete
|
||||||
|
version of the hunk, not "removals here, additions there".
|
||||||
|
|
||||||
|
```
|
||||||
|
LEFT (old) RIGHT (new)
|
||||||
|
12 public void Save() 12 public void Save()
|
||||||
|
13 var x = 1; 13 var x = 2; ← word diff on `1` / `2`
|
||||||
|
14 Log("old"); · (filler)
|
||||||
|
· (filler) 14 Log("new");
|
||||||
|
15 } 15 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. `DiffAlignment` (new, pure)
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`
|
||||||
|
|
||||||
|
Turns `IReadOnlyList<DiffLineViewModel>` into a render-ready `AlignedDiff`. No Avalonia types,
|
||||||
|
fully unit-testable.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
|
||||||
|
|
||||||
|
public readonly record struct TextSpan(int Start, int Length);
|
||||||
|
|
||||||
|
public sealed record SplitRow(
|
||||||
|
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
|
||||||
|
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
|
||||||
|
|
||||||
|
public sealed record UnifiedRow(
|
||||||
|
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
|
||||||
|
|
||||||
|
public sealed record AlignedDiff(
|
||||||
|
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
|
||||||
|
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText);
|
||||||
|
```
|
||||||
|
|
||||||
|
Row index `i` maps to document line `i + 1` in the corresponding text. That mapping is the
|
||||||
|
contract the margin and both renderers depend on.
|
||||||
|
|
||||||
|
**Pairing.** Walk the lines. A `Ctx` run emits rows with the same text on both sides. A change
|
||||||
|
block (a `Del` run followed by an `Add` run) pairs index-wise up to `min(delCount, addCount)`;
|
||||||
|
the overhang gets `Filler` rows on the opposite side.
|
||||||
|
|
||||||
|
**Gaps.** The parser drops `@@` headers, so a skipped region shows up as a jump in `OldNo`/`NewNo`
|
||||||
|
between consecutive lines. `DiffAlignment` detects that jump and inserts a `Gap` row on both
|
||||||
|
sides. The parser is not touched.
|
||||||
|
|
||||||
|
**Word diff.** Only for `(Del, Add)` rows that are paired 1:1. Tokenize each side into runs of
|
||||||
|
word characters / whitespace / single punctuation, run an LCS over the tokens, and emit the
|
||||||
|
changed token runs as character spans per side.
|
||||||
|
|
||||||
|
Two guards, both `const` and both covered by tests:
|
||||||
|
- Skip when either side exceeds `MaxWordDiffChars = 2000` — LCS cost, and such lines are
|
||||||
|
unreadable as word diffs anyway.
|
||||||
|
- Skip when token similarity is below `MinWordDiffSimilarity = 0.5` (common tokens / max token
|
||||||
|
count). Below that the two lines are unrelated rewrites and per-word tinting is noise.
|
||||||
|
|
||||||
|
### 2. `DiffTextView` (new control, replaces `DiffLinesView`)
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs`
|
||||||
|
|
||||||
|
Styled properties:
|
||||||
|
|
||||||
|
| Property | Type | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `File` | `DiffFileViewModel?` | Source; the control aligns it and caches the `AlignedDiff` per file instance |
|
||||||
|
| `Mode` | `DiffViewMode` (`Unified`\|`Split`) | Layout |
|
||||||
|
| `WrapLines` | `bool` | Bound to each editor's `WordWrap` |
|
||||||
|
|
||||||
|
Two `TextEditor`s in a two-column grid, both `IsReadOnly=true`, `ShowLineNumbers=false`.
|
||||||
|
`Unified` mode collapses the right editor and spans the left one across both columns, feeding
|
||||||
|
it `UnifiedText`. `Split` mode shows both, fed `LeftText` / `RightText`.
|
||||||
|
|
||||||
|
Per editor:
|
||||||
|
- **TextMate**: one shared `RegistryOptions(ThemeName.DarkPlus)`; grammar resolved from
|
||||||
|
`File.Path`'s extension via `GetLanguageByExtension` → `GetScopeByLanguageId` → `SetGrammar`,
|
||||||
|
exactly as `ConflictResolverView.ApplyGrammar` does. No extension match → no grammar, plain text.
|
||||||
|
- **`DiffLineNumberMargin : AbstractMargin`** — draws line numbers from the row list. Split: old
|
||||||
|
numbers left, new numbers right. Unified: two number columns in one margin. `Filler` and `Gap`
|
||||||
|
rows draw nothing.
|
||||||
|
- **`DiffLineBackgroundRenderer : IBackgroundRenderer`** — full-width tint per visual line by
|
||||||
|
row kind: add / del / filler / gap / ctx.
|
||||||
|
- **`WordDiffRenderer : IBackgroundRenderer`** — stronger tint over the changed spans, via
|
||||||
|
`BackgroundGeometryBuilder` at `lineStartOffset + span.Start`.
|
||||||
|
|
||||||
|
Both renderers resolve rows through a single `Func<int, RowInfo?>` keyed by document line.
|
||||||
|
|
||||||
|
**Highlighting on fragments.** Only hunks are in the document, not whole files, so TextMate's
|
||||||
|
line-by-line state can be wrong at a fragment boundary (a line inside a block comment may be
|
||||||
|
highlighted as code). Accepted — the same is true of every fragment-based diff viewer.
|
||||||
|
|
||||||
|
**Colors.** Line tints stay the existing low-alpha `RunningTintBrush` / `ErrorTintBrush` so
|
||||||
|
syntax foregrounds remain legible. The per-line foreground recolor from `DiffLinesView`
|
||||||
|
(green/red text) is dropped — syntax colors take over. `Filler` gets a new dim token brush,
|
||||||
|
`Gap` renders as a dim `⋯` separator row.
|
||||||
|
|
||||||
|
**Scroll sync** (split only), modeled on `ConflictResolverView.HookScrollSync` — find each
|
||||||
|
editor's descendant `ScrollViewer`, guard re-entry with a `_syncing` flag:
|
||||||
|
- `WrapLines = false`: sync `Offset.Y` directly. Line heights match, so alignment is exact.
|
||||||
|
- `WrapLines = true`: line heights diverge. Sync on the first visible document line instead
|
||||||
|
(`ScrollToLine`), which keeps the top of the viewport aligned and lets rows drift downward.
|
||||||
|
|
||||||
|
### 3. Host changes
|
||||||
|
|
||||||
|
`DiffViewerView.axaml`:
|
||||||
|
- Header gains a segmented Unified/Split toggle and a wrap toggle.
|
||||||
|
- Files mode: `DiffLinesView Lines="{Binding SelectedFile.Lines}"` → `DiffTextView File="{Binding SelectedFile}"`.
|
||||||
|
- Planning mode: the flattened single-stream `DiffLines` view is replaced by an `ItemsControl`
|
||||||
|
over the subtask's parsed files, each item a file header plus its own `DiffTextView`. One
|
||||||
|
editor can only carry one grammar, so per-file editors are required for highlighting to work
|
||||||
|
at all here. `DiffViewerViewModel` exposes the parsed per-file list for the selected subtask;
|
||||||
|
`DiffLines` and `UnifiedDiffParser.Flatten` lose their last consumer and are removed.
|
||||||
|
|
||||||
|
`DiffLinesView.axaml` + `.axaml.cs` are deleted once both usages are migrated.
|
||||||
|
|
||||||
|
### 4. Persistence
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/AppSettings.cs` (`~/.todo-app/ui.config.json`) already holds UI-only
|
||||||
|
preferences (`Language`, `AccentPreset`) with plain `Load()`/`Save()`. Two properties are added
|
||||||
|
there:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string DiffViewMode { get; set; } = "unified"; // "unified" | "split"
|
||||||
|
public bool DiffWrapLines { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`DiffViewerViewModel` takes the injected `AppSettings`, seeds its toggles on open and calls
|
||||||
|
`Save()` when either changes. No database column, no EF migration, no hub method — a view
|
||||||
|
preference does not belong in `AppSettingsEntity`.
|
||||||
|
|
||||||
|
### 5. Localization
|
||||||
|
|
||||||
|
New keys in both `locales/en.json` and `locales/de.json` (Localization.Tests enforces parity):
|
||||||
|
`diff.view.unified`, `diff.view.split`, `diff.view.wrap`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`tests/ClaudeDo.Ui.Tests` — `DiffAlignment` is pure and carries the logic worth testing:
|
||||||
|
|
||||||
|
- Context-only diff → identical rows on both sides, no fillers.
|
||||||
|
- Equal-size change block → 1:1 pairing, no fillers.
|
||||||
|
- Unequal change block (3 del / 5 add) → 3 paired rows + 2 right-side rows with left fillers.
|
||||||
|
- Add-only and delete-only blocks → fillers on the opposite side throughout.
|
||||||
|
- Non-contiguous line numbers → exactly one `Gap` row inserted.
|
||||||
|
- Word diff: single-token change yields one span per side at the right offsets.
|
||||||
|
- Word diff skipped above `MaxWordDiffChars` and below `MinWordDiffSimilarity`.
|
||||||
|
- Row index ↔ document line mapping holds for both `SplitRows` and `UnifiedRows`.
|
||||||
|
- Binary file and empty-content file → empty `AlignedDiff`, no crash.
|
||||||
|
|
||||||
|
`AppSettings` round-trip: persisted mode and wrap survive `Save()`/`Load()`.
|
||||||
|
|
||||||
|
Rendering (margin, both renderers, scroll sync, TextMate colors) is not unit-testable here and
|
||||||
|
is an explicit manual visual pass — see Open items.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
1. **Wrap + split drift.** With wrap on, the two panes align at the top of the viewport but rows
|
||||||
|
drift apart further down. Per-line vertical alignment as VS Code does it is out of scope.
|
||||||
|
2. **Fragment highlighting.** See above — highlighting state can be wrong at hunk boundaries.
|
||||||
|
3. **Editors per file in Planning mode.** A subtask touching many files instantiates one editor
|
||||||
|
per file. Same cost as viewing those files individually in Files mode; not capped. If it
|
||||||
|
proves slow, the fix is lazy instantiation on expand, not a silent truncation.
|
||||||
|
|
||||||
|
## Open items (manual verification)
|
||||||
|
|
||||||
|
- Visual pass on both modes: tints legible over DarkPlus syntax colors; line numbers aligned;
|
||||||
|
filler and gap rows readable.
|
||||||
|
- Scroll sync with wrap off (exact) and wrap on (top-anchored).
|
||||||
|
- Planning mode with a multi-file subtask.
|
||||||
|
- Toggle state survives an app restart.
|
||||||
|
|
||||||
|
## Docs to update on completion
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Ui/CLAUDE.md` — Views/Controls list and the "Diff & Conflicts" section still
|
||||||
|
name `DiffLinesView`.
|
||||||
|
- `docs/explore-notes/review-merge.md` — diff stack description + "verified against" commit.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Handler-Run: Verknüpfung zu den behandelten Tasks
|
||||||
|
|
||||||
|
**Date:** 2026-08-07
|
||||||
|
**Status:** Design approved (Mika), implementation pending
|
||||||
|
**Verified against:** commit `c792765`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
|
||||||
|
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
|
||||||
|
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
|
||||||
|
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
|
||||||
|
bekommt `survivingTaskIds` als flüchtige Liste.
|
||||||
|
|
||||||
|
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
|
||||||
|
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
|
||||||
|
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
|
||||||
|
|
||||||
|
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
|
||||||
|
irreführend, denn es ist kein manueller Reminder.
|
||||||
|
|
||||||
|
## Ist-Zustand
|
||||||
|
|
||||||
|
### Es gibt kein Task-Kind
|
||||||
|
|
||||||
|
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
|
||||||
|
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
|
||||||
|
| `PlanningPhase` | Planning-Parent |
|
||||||
|
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
|
||||||
|
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
|
||||||
|
|
||||||
|
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
|
||||||
|
|
||||||
|
### `ParentTaskId` ist belegt
|
||||||
|
|
||||||
|
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
|
||||||
|
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
|
||||||
|
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
|
||||||
|
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
|
||||||
|
schieben und mit echten Planning-Kindern kollidieren.
|
||||||
|
|
||||||
|
### Badge-Infrastruktur existiert
|
||||||
|
|
||||||
|
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
|
||||||
|
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
|
||||||
|
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
|
||||||
|
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
|
||||||
|
|
||||||
|
### Kinder-Panel existiert
|
||||||
|
|
||||||
|
`DetailsIslandViewModel.LoadChildOutcomesAsync`
|
||||||
|
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
|
||||||
|
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
|
||||||
|
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
|
||||||
|
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
| Frage | Entscheidung | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
|
||||||
|
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
|
||||||
|
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
|
||||||
|
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
|
||||||
|
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Daten
|
||||||
|
|
||||||
|
Neue nullable Spalte auf `TaskEntity`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
|
||||||
|
public string? HandlerTaskId { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
|
||||||
|
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
|
||||||
|
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
|
||||||
|
|
||||||
|
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
|
||||||
|
|
||||||
|
### 2. Schreiben
|
||||||
|
|
||||||
|
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync` →
|
||||||
|
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
|
||||||
|
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
|
||||||
|
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
|
||||||
|
still übersprungen.
|
||||||
|
|
||||||
|
### 3. Badge
|
||||||
|
|
||||||
|
`TaskRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||||
|
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||||
|
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
|
||||||
|
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
|
||||||
|
|
||||||
|
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
|
||||||
|
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
|
||||||
|
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
|
||||||
|
|
||||||
|
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
|
||||||
|
|
||||||
|
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
|
||||||
|
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
|
||||||
|
Tasks, die er bearbeitet hat"
|
||||||
|
|
||||||
|
### 4. Anzeige
|
||||||
|
|
||||||
|
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
|
||||||
|
Kinder-Panel:
|
||||||
|
|
||||||
|
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
|
||||||
|
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
|
||||||
|
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
|
||||||
|
WorktreeState) — keine neue Row-Klasse.
|
||||||
|
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
|
||||||
|
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
|
||||||
|
- Sichtbar nur wenn `HandledTasks.Count > 0`.
|
||||||
|
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
|
||||||
|
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
|
||||||
|
springen" wäre neues Verhalten und ist hier nicht enthalten.
|
||||||
|
|
||||||
|
### 5. Fehlerfälle
|
||||||
|
|
||||||
|
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
|
||||||
|
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
|
||||||
|
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
|
||||||
|
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Ebene | Test |
|
||||||
|
|---|---|
|
||||||
|
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
|
||||||
|
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
|
||||||
|
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
|
||||||
|
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
|
||||||
|
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
|
||||||
|
|
||||||
|
## Bewusst nicht enthalten
|
||||||
|
|
||||||
|
- Kein `TaskKind`-Enum.
|
||||||
|
- Keine n:m-Historie über mehrere Runs.
|
||||||
|
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
|
||||||
|
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
|
||||||
|
- Kein Badge auf den *behandelten* Tasks.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
|
||||||
|
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# UI-Reaktivität und Listen-Performance
|
||||||
|
|
||||||
|
**Datum:** 2026-08-07
|
||||||
|
**Status:** Design freigegeben, Implementierung offen
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Zwei Symptome, die als eines gemeldet wurden:
|
||||||
|
|
||||||
|
1. **Stale UI.** Ein Task bleibt in der Liste auf `Queued` stehen, obwohl der Worker ihn längst auf `Running` gesetzt hat. Ebenso tauchen extern angelegte Tasks (Online-Inbox, List-Handler) erst nach einem manuellen Neuladen auf. Modals und Overlays zeigen den Stand vom Öffnungszeitpunkt.
|
||||||
|
2. **Langsames Laden.** Eine Liste mit ~125 erledigten Tasks braucht 1–2 Sekunden zum Öffnen. Ziel sind Listen mit bis zu ~1000 erledigten Tasks.
|
||||||
|
|
||||||
|
## Analyse
|
||||||
|
|
||||||
|
### Reaktivität: es fehlt kein Event, es fehlt die Selbstheilung
|
||||||
|
|
||||||
|
Der Broadcast-Pfad ist im Grundsatz korrekt: der Worker schreibt in die DB, committet, und sendet danach eine ID über SignalR (`HubBroadcaster`); die UI lädt die Entity frisch nach. WAL-Sichtbarkeit und EF-Change-Tracking wurden als Ursache **ausgeschlossen** — die UI nutzt `IDbContextFactory` mit kurzlebigen Kontexten, und WAL-Reader sehen Commits sofort.
|
||||||
|
|
||||||
|
Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der einzige Reconcile-Trigger ist heute `ConnectionRestoredEvent`, also ein Verbindungsabbruch. Es gibt drei Wege, auf denen ein Update verloren geht:
|
||||||
|
|
||||||
|
| # | Loch | Ort |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
|
||||||
|
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
|
||||||
|
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` |
|
||||||
|
|
||||||
|
**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen.
|
||||||
|
|
||||||
|
Dazu zwei kleinere Befunde:
|
||||||
|
|
||||||
|
- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
|
||||||
|
- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout` → `30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout.
|
||||||
|
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.
|
||||||
|
|
||||||
|
### Performance: der Engpass ist das Rendering, nicht die Datenbank
|
||||||
|
|
||||||
|
SQLite ist hier **nicht** der Engpass, und ein DB-Wechsel würde nichts verbessern. Die Kosten verteilen sich so:
|
||||||
|
|
||||||
|
| Posten | bei 125 Zeilen |
|
||||||
|
|---|---|
|
||||||
|
| SQLite-Read, 125 Zeilen × ~30 Spalten, 2 Joins | < 1 ms |
|
||||||
|
| EF-Materialisierung | ~1–5 ms |
|
||||||
|
| **Avalonia baut ~8.500 Controls mit Bindings** | **~1.000–1.500 ms** |
|
||||||
|
|
||||||
|
Eine `TaskRowView` erzeugt **~68 Controls eager**: ~50 für Struktur und Inhalt plus 18 `MenuItem`-Deklarationen des inline deklarierten ContextMenus (`TaskRowView.axaml:35-85`). Die Liste ist **nicht virtualisiert**: drei `ItemsControl` (Overdue/Open/Completed) liegen in einem gemeinsamen `ScrollViewer` ohne `ItemsPanel`-Override (`TasksIslandView.axaml:100,119,152`). `ItemsControl` nutzt per Default ein normales `StackPanel`, und der gemeinsame `ScrollViewer` gibt allen dreien unbegrenzte Höhe — deshalb würde auch ein bloßes Setzen von `VirtualizingStackPanel` nichts bewirken.
|
||||||
|
|
||||||
|
Zeilenhöhe ~68px, verfügbare Listenhöhe auf 2560×1440 ~1250px → **~19 Zeilen gleichzeitig sichtbar**.
|
||||||
|
|
||||||
|
| | Controls | geschätzt |
|
||||||
|
|---|---|---|
|
||||||
|
| heute, 125 Tasks | ~8.500 | 1–2 s |
|
||||||
|
| heute, 1000 Tasks | ~68.000 | ~10 s+ |
|
||||||
|
| virtualisiert (19 sichtbar + Overscan ≈ 25 Zeilen) | ~1.700 | ~250 ms |
|
||||||
|
| + ContextMenu lazy | ~1.250 | ~180 ms |
|
||||||
|
|
||||||
|
Entscheidend: die virtualisierten Werte sind **konstant** und gelten für 125 wie für 10.000 Zeilen.
|
||||||
|
|
||||||
|
Nebenbefund: `LoadForList` (`TasksIslandViewModel.cs:305-309`) hat **kein `.Where()` vor `ToListAsync()`** — es lädt die komplette `tasks`-Tabelle aller Listen mit zwei Joins und filtert danach in C#. Die vorhandenen Indizes (`idx_tasks_list_id`, `idx_tasks_status`) werden dadurch nie genutzt. Bei der heutigen DB-Größe unkritisch, aber es skaliert mit der DB-Gesamtgröße statt mit der Listengröße.
|
||||||
|
|
||||||
|
### Drag & Drop: architektonisch virtualisierungsfähig
|
||||||
|
|
||||||
|
Die Task-Liste nutzt **kein** Avalonia-`DragDrop` pro Item, sondern ein eigenes Ghost-Drag:
|
||||||
|
|
||||||
|
- Vier Pointer-Handler hängen **zentral** an der `TasksIslandView` (`TasksIslandView.axaml.cs:40-43`, Tunnel-Routing) — keine pro-Zeile registrierten Handler, die beim Container-Recycling leaken könnten.
|
||||||
|
- Das Ziel wird per `InputHitTest` live ermittelt (`TasksIslandView.axaml.cs:304-329`) — kein Index-Hack, recycling-sicher.
|
||||||
|
- Drop-Hints sind reine ViewModel-Properties (`TaskRowViewModel.cs:25-26`, `DropHintAbove`/`DropHintBelow`).
|
||||||
|
- Der Ghost ist ein `RenderTargetBitmap`-Snapshot in einem separaten Topmost-Fenster (`Views/Controls/TaskDragController.cs`), losgelöst vom Visual Tree.
|
||||||
|
|
||||||
|
Anzupassen sind:
|
||||||
|
|
||||||
|
- `FindNextInSameSection` und `SectionFor` (`TasksIslandViewModel.cs:366-374`, `:622-628`) iterieren per `IndexOf` über die drei UI-Collections. Die flache Master-Collection `Items` existiert bereits (`TasksIslandViewModel.cs:55`) und ist korrekt sortiert — das ist der Grund, warum das Flatten bezahlbar ist.
|
||||||
|
- **Auto-Scroll beim Ziehen fehlt komplett.** Fällt heute weniger auf, weil alle Container realisiert sind; bei einer virtualisierten Liste ist es Pflicht.
|
||||||
|
- **Bestehender Bug:** Zieht man über eine Gruppengrenze, prüft `ReorderAsync` (`TasksIslandViewModel.cs:560-563`) zwar die Sektion, verschiebt bei ungleichen Sektionen aber nur `Items` ohne anschließendes `Regroup()`. `SortOrder` landet in der DB, die UI zeigt nichts.
|
||||||
|
- **Kein Präzedenzfall:** im gesamten `ClaudeDo.Ui`-Projekt existiert kein `VirtualizingStackPanel` und kein `ItemsRepeater`.
|
||||||
|
|
||||||
|
### Drag-Optik
|
||||||
|
|
||||||
|
Heute laufen zwei Darstellungen derselben Zeile gleichzeitig: der Bitmap-Ghost am Cursor **und** die Originalzeile mit `Opacity 0.55`, `scale(1.03)`, BoxShadow und Accent-Rand (`IslandStyles.axaml:441-446`). `scale(1.03)` ändert kein Layout und überlappt daher die Nachbarzeilen. Das Feedback für das Drop-Ziel ist der ganz normale `:pointerover`-Hover (`IslandStyles.axaml:433-435`, setzt nur `BorderBrush`) — dasselbe Signal wie beim harmlosen Drüberfahren; einen eigenen `drop-target`-Style gibt es nur für die Lists-Island (`Border.list-item.drop-target`). Zusätzlich laufen `BrushTransition` (0.12s) und `ThicknessTransition` auf `Margin` (0.15s) auch während des Drags mit, was die Rückmeldung verschwimmen lässt.
|
||||||
|
|
||||||
|
## Verworfene Alternativen
|
||||||
|
|
||||||
|
| Alternative | Warum verworfen |
|
||||||
|
|---|---|
|
||||||
|
| **SQLite ersetzen** | Kein Engpass. Der DB-Read liegt unter 1 ms; die Zeit steckt zu >95% im Aufbau des Visual Tree. Monatelange Arbeit für null messbaren Gewinn. |
|
||||||
|
| **Nur Task-Titel laden, Rest lazy beim Öffnen** | Richtige Intuition, falsche Ebene. Spart Bytes aus einer lokalen Datei, die in unter 1 ms gelesen wird. Die 125 Zeilen werden weiterhin als 125 vollständige `TaskRowView` gebaut. Um wirklich zu sparen, müsste das Zeilen-Template entkernt werden — also genau die Chips und Icons entfallen, wegen derer die Liste nützlich ist. Als *Zusatz* (schlanke Projektion für Speicher/Materialisierung) sinnvoll, als Hauptmaßnahme nicht. |
|
||||||
|
| **Completed einklappen + „mehr laden"** | Billig und sofort wirksam, aber aufgeklappt mit 1000 Zeilen hängt es wieder. Bleibt als **Fallback**, falls der Virtualisierungs-Spike scheitert. |
|
||||||
|
| **Completed archivieren** | Löst das Problem durch Vermeidung; der Wunsch war ausdrücklich, 1000 erledigte Tasks sehen zu können. |
|
||||||
|
| **`Revision`-Spalte / Change-Feed** | Strukturell sauber (verlorene Events wären egal, Race gelöst), aber Migration plus Anpassung jedes Schreibpfads. Für eine Single-User-Desktop-App mit lokaler DB Overkill; der Reconcile-Tick erreicht dasselbe Ziel deutlich billiger. Bleibt als Eskalation, falls Phase 3 in der Praxis nicht reicht. |
|
||||||
|
| **Nur die Löcher stopfen (ohne Reconcile)** | Behebt die bekannten Fälle, lässt die Architektur „ein verlorener Event = permanent stale" aber intakt. Das nächste Loch kommt mit dem nächsten Feature. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Phase 1 — Reaktivitäts-Löcher schließen
|
||||||
|
|
||||||
|
Unabhängig von Phase 2 und 3, kann sofort starten.
|
||||||
|
|
||||||
|
| Fix | Ort |
|
||||||
|
|---|---|
|
||||||
|
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
|
||||||
|
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
|
||||||
|
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
|
||||||
|
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
|
||||||
|
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
|
||||||
|
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
|
||||||
|
|
||||||
|
Der ungeschützte Setup-Block in `TaskRunner.ContinueAsync` (Zeilen 218–238) wird **nicht** separat umgebaut: sobald `RunInSlotAsync` im Fehlerfall `FailAsync` ruft, ist jede dort geworfene Exception abgedeckt — der Task landet auf `Failed` und der Broadcast erfolgt. Ein zweiter Schutzwall wäre doppelt.
|
||||||
|
|
||||||
|
### Phase 2 — Flache virtualisierte Liste
|
||||||
|
|
||||||
|
**Datenmodell.** `Regroup()` erzeugt statt drei Collections **eine** `Rows`-Collection vom Union-Typ (`HeaderRow` | `TaskRowViewModel`), abgeleitet aus der bereits vorhandenen flachen `Items`. Gruppenüberschriften werden zu regulären Einträgen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Rows
|
||||||
|
[0] HeaderRow "Überfällig (3)"
|
||||||
|
[1] TaskRow …
|
||||||
|
[4] HeaderRow "Offen (12)"
|
||||||
|
…
|
||||||
|
[17] HeaderRow "Erledigt (125)"
|
||||||
|
…
|
||||||
|
```
|
||||||
|
|
||||||
|
**View.** Eine `ListBox` mit `VirtualizingStackPanel` und einem DataTemplate-Selector (Header / Task) ersetzt die drei `ItemsControl` und den umschließenden `ScrollViewer`.
|
||||||
|
|
||||||
|
**Drag.** `FindNextInSameSection`, `SectionFor` und `ReorderAsync` rechnen gegen `Items` statt gegen die UI-Collections. Auto-Scroll beim Ziehen an den Listenrand wird neu gebaut. Der Cross-Section-Reorder-Bug wird im selben Zug behoben, da die Sektionsgrenzen in der flachen Struktur ohnehin explizit modelliert werden müssen.
|
||||||
|
|
||||||
|
**Zeilenkosten.** Das ContextMenu wird bei `ContextRequested` im Code-Behind aufgebaut statt als 18 `MenuItem`s pro Template-Instanz.
|
||||||
|
|
||||||
|
**Query.** `.Where()` wandert vor `ToListAsync()`, damit die Query mit der Listengröße statt der DB-Gesamtgröße skaliert. Erfordert Umbau von `ITaskListFilter` von In-Memory-Prädikaten (`Matches(TaskEntity)`) auf `IQueryable`-Expressions.
|
||||||
|
|
||||||
|
**Drag-Optik (Variante A).** Ghost folgt dem Cursor; die Originalzeile kollabiert zu einer leeren, gestrichelten Lücke, die beim Ziehen an die jeweilige Zielposition mitwandert.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Fix login bug │
|
||||||
|
├──────────────────────┤
|
||||||
|
│ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │ ← leerer Slot, wandert mit
|
||||||
|
├──────────────────────┤
|
||||||
|
│ Rebase branch │
|
||||||
|
└──────────────────────┘
|
||||||
|
┌────────────────┐
|
||||||
|
│ Update deps │ ← Ghost am Cursor
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Begleitend: `scale(1.03)` und BoxShadow auf der gezogenen Zeile entfallen; der normale `:pointerover`-Hover wird während eines aktiven Drags per `dragging`-Klasse am Container unterdrückt; Transitions sind während des Drags aus.
|
||||||
|
|
||||||
|
### Phase 3 — Reconcile-Tick
|
||||||
|
|
||||||
|
Setzt Phase 2 voraus: auf einer Liste, die 1–2 s zum Laden braucht, würde ein periodischer Abgleich alles verschlimmern.
|
||||||
|
|
||||||
|
Ein Timer (3–5 s) gleicht die **sichtbaren** Rows gegen die lokale SQLite ab und **patcht ausschließlich Properties** — er baut nie Zeilen neu und löst nie einen `LoadForList` aus. Bei einer lokalen DB und wenigen Dutzend sichtbaren Zeilen ist das ein einzelner indizierter Query.
|
||||||
|
|
||||||
|
Damit ist jeder verlorene Event nach spätestens einem Tick geheilt, unabhängig davon, wo er fehlte. Derselbe Tick versorgt zusätzlich die langlebigen Overlays: Worktrees-Overview, LogVisualizer und die MergeHelper-Auswahl.
|
||||||
|
|
||||||
|
Kurzlebige Modals (Settings, ListSettings, RepoImport, WeeklyReport, ConflictResolver) bleiben bewusst statisch — ein Dialog, der sich unter den Fingern des Nutzers ändert, ist schlechter als einer, der den Stand vom Öffnen zeigt.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Worker.Tests:** Broadcast-Assertions über einen Fake-Broadcaster auf allen in Phase 1 gefixten Pfaden — insbesondere, dass der Fehlerfall in `RunInSlotAsync` einen Broadcast auslöst.
|
||||||
|
- **Ui.Tests:** Der Reconcile-Tick patcht abweichende Properties; ein Ergebnis mit veralteter Sequenznummer wird verworfen; `Regroup()` erzeugt die korrekte `Rows`-Folge inklusive Header-Positionen und -Zählern.
|
||||||
|
- **Nicht automatisiert testbar:** Ladezeit und Drag-Optik. Beides erfordert eine manuelle Gegenmessung mit einer großen Liste durch den Nutzer.
|
||||||
|
|
||||||
|
Keine Tests, die die echte `claude`-CLI starten.
|
||||||
|
|
||||||
|
## Risiken
|
||||||
|
|
||||||
|
1. **Kein Virtualisierungs-Präzedenzfall im Projekt.** Das HitTest-basierte Custom-Drag ist theoretisch tragfähig, aber nie gegen recycelte Container verifiziert. **Die erste Aufgabe in Phase 2 ist ein Spike**, kein Umbau: eine virtualisierte Liste mit dem bestehenden Drag, inklusive Recycling während eines aktiven Drags und Auto-Scroll. Scheitert der Spike, ist der Fallback „Completed einklappen + nachladen".
|
||||||
|
2. **Variable Zeilenhöhen.** 68 px im Normalfall, 90–110 px bei zweizeiligem Titel oder mehreren Badges. `VirtualizingStackPanel` beherrscht das, aber die Scrollbar springt dabei gern, weil die Gesamthöhe geschätzt wird. Muss im Spike mitgeprüft werden.
|
||||||
|
3. **`ITaskListFilter`-Umbau.** Die Umstellung auf `IQueryable`-Expressions berührt die Filter-Registry (`src/ClaudeDo.Data/Filtering/`) und damit auch die virtuellen Listen. Kann bei Bedarf aus Phase 2 herausgelöst und nachgezogen werden — der Performance-Gewinn liegt heute ohnehin fast vollständig beim Rendering.
|
||||||
|
|
||||||
|
## Reihenfolge
|
||||||
|
|
||||||
|
Phase 1 → Phase 2 (Spike zuerst) → Phase 3. Phase 1 ist unabhängig und kann parallel oder vorab laufen.
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Findings-Store — Design
|
||||||
|
|
||||||
|
Stand: 2026-08-10.
|
||||||
|
|
||||||
|
Persistenter, projektgebundener Speicher für **Fallen und Invarianten**, den Agents im
|
||||||
|
Moment des Schmerzes selbst befüllen und in späteren Sessions vor dem Erkunden lesen.
|
||||||
|
Ziel ist Token-Ersparnis durch verhinderte Fehlläufe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangslage
|
||||||
|
|
||||||
|
Auslöser war die Frage, ob ein Code-Knowledge-Graph (Graphify-Bauart) das
|
||||||
|
Wiedererkunden in jeder Session einspart. Die Messung sagt: nein, nicht in dieser Form.
|
||||||
|
|
||||||
|
### Die Zahlen (aus `docs/usage-optimization.md`, 2026-08-05)
|
||||||
|
|
||||||
|
- Kontext-Resend = **99,3 %** des Rohverbrauchs. Jeder Kontext-Token wird im Schnitt
|
||||||
|
**~425× nachberechnet**. Das gilt symmetrisch: für gespartes *und* für geladenes Wissen.
|
||||||
|
- **Explore-note ersetzt Exploration:** Note 4,6k tok in Turn 5 eines 60-Turn-Runs
|
||||||
|
→ 4,6k × 55 ≈ **250k**. Ersparte Exploration (~8k tok Findings ab Turn 10) ≈ **400k**.
|
||||||
|
Netto ~150k, Faktor 1,6. Wird die Note geladen, ohne gebraucht zu werden: **−250k**.
|
||||||
|
- **Ein verhinderter Retry:** ~11 Mio Token. **27 % der Tasks brauchten einen Retry.**
|
||||||
|
|
||||||
|
Faktor 27 zwischen den beiden Klassen. Deshalb ist der Inhalt dieses Stores **nicht**
|
||||||
|
„Architektur-Map, damit ich nicht neu erkunden muss" (400k-Klasse), sondern „Agent läuft
|
||||||
|
in die bekannte Falle und der Run ist Müll" (11-Mio-Klasse).
|
||||||
|
|
||||||
|
### Was heute schon da ist
|
||||||
|
|
||||||
|
| Ebene | Größe | Ladeverhalten |
|
||||||
|
|---|---:|---|
|
||||||
|
| `CLAUDE.md` (root) | 6,4 KB ≈ 1,6k tok | immer |
|
||||||
|
| `src/*/CLAUDE.md` (6×) | 1,5–14 KB | beim Arbeiten im Verzeichnis |
|
||||||
|
| `docs/explore-notes/` (6 Notes) | 11–18 KB, 92 KB gesamt | on demand, **ganze Datei** |
|
||||||
|
| Auto-Memory (`~/.claude/projects/.../memory/`) | 55 Dateien, 397 KB | Index immer (nur interaktiv) |
|
||||||
|
|
||||||
|
Die Sektionen der Notes sind bereits 12–36 Zeilen (~150–450 tok) — also schon
|
||||||
|
knotengroß. Und `conpty-sessions.md` markiert Fallen bereits konventionell als
|
||||||
|
`## ⚠️ Gotcha: …` (5 Stück). Die anderen Notes tragen dieselbe Art Wissen unmarkiert im
|
||||||
|
Fließtext. **Das Wissen ist zu großen Teilen schon geschrieben, nur nicht einsammelbar.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verworfene Alternativen
|
||||||
|
|
||||||
|
- **Code-Knowledge-Graph (Graphify o. ä.).** Ein tree-sitter-Graph liefert Call-Kanten —
|
||||||
|
genau die Klasse, die Grep exakt und billig liefert (Grep = 5–8 % des Kontexts; Read =
|
||||||
|
60–68 %, das Problem ist zu viel *lesen*, nicht Finden). Der wertvolle Inhalt ist
|
||||||
|
semantisch und nicht ableitbar („Approve = merge the whole unit", „no directory arg may
|
||||||
|
end in a separator"). Dazu: Build-/Staleness-Pipeline über N parallele Worktrees und
|
||||||
|
Merge-Konflikte auf einem Graph-Artefakt im Repo. AXAML wird ohnehin nicht geparst.
|
||||||
|
- **DB-Tabelle + UI-Editor.** Kuration passiert in VSCode, damit entfällt der einzige
|
||||||
|
Vorteil. Agent-Zugriff bräuchte ein Lese-Tool statt `Read`; Agents könnten veraltete
|
||||||
|
Einträge nicht selbst korrigieren.
|
||||||
|
- **Hook-Injektion der Findings in den System-Prompt.** Früh und groß in den Prefix ist
|
||||||
|
das teuerste Muster überhaupt (Befund 5), auch wenn nichts davon gebraucht wird.
|
||||||
|
- **Post-Merge-Trigger.** Ursprünglicher Entwurf. Hinfällig, weil der Aufrufer des Tools
|
||||||
|
den Kontext bereits hat — siehe §4.
|
||||||
|
- **Retro-Distill aus alten Transkripten.** Zurückgestellt, siehe §8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Speicher
|
||||||
|
|
||||||
|
```
|
||||||
|
<working-dir>/.claudedo/
|
||||||
|
INDEX.md # eine Zeile je Finding — die einzige Datei, die immer gelesen wird
|
||||||
|
traps/<slug>.md # ein Finding je Datei, ~150–300 tok
|
||||||
|
```
|
||||||
|
|
||||||
|
`maps/` ist reserviert, aber **nicht Teil von v1**.
|
||||||
|
|
||||||
|
Ordner liegt im Repo-Root, das er beschreibt — maximale Erkennbarkeit, reist bei
|
||||||
|
Bedarf mit. `.claudedo` ist als Name frei; die Worktrees liegen unter dem *Geschwister*-Pfad
|
||||||
|
`.claudedo-worktrees`, und `TranscriptUsageReader` prüft auf das exakte Segment, also kein
|
||||||
|
Fehlalarm bei der Scope-Erkennung.
|
||||||
|
|
||||||
|
### Finding-Format
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
slug: conpty-arg-quoting
|
||||||
|
scope: src/ClaudeDo.Worker/Planning
|
||||||
|
source-task: 5d627df8
|
||||||
|
verified-against: e10634b
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ein Verzeichnis-Argument, das auf `\` endet, frisst alle folgenden Argumente
|
||||||
|
|
||||||
|
<2–6 Sätze: was passiert, warum es nicht danach aussieht, was man stattdessen tut.>
|
||||||
|
```
|
||||||
|
|
||||||
|
### INDEX.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Findings
|
||||||
|
|
||||||
|
- [conpty-arg-quoting](traps/conpty-arg-quoting.md) — Dir-Arg auf `\` escapet sein eigenes Quote
|
||||||
|
- [shared-worktree-partial-commit](traps/shared-worktree-partial-commit.md) — nie blank committen
|
||||||
|
```
|
||||||
|
|
||||||
|
Reine Routing-Ebene: Titel + einzeiliger Aufhänger + Pfad. Ablauf im Agenten:
|
||||||
|
Index lesen (~700 tok) → ein bis zwei Findings lesen (~400 tok), statt 4 600 für eine
|
||||||
|
ganze Note.
|
||||||
|
|
||||||
|
### Einchecken oder nicht — Toggle je Liste
|
||||||
|
|
||||||
|
Beim Anlegen einer Liste: **„`.claudedo` einchecken"**, Default **aus**.
|
||||||
|
|
||||||
|
- **Aus** → `/.claudedo/` wird nach `.git/info/exclude` geschrieben (per-Clone,
|
||||||
|
unsichtbar fürs Repo, **keine getrackte Datei angefasst**). Mechanik existiert bereits in
|
||||||
|
`SessionSkillSeeder.AppendExcludeLineAsync` (auflösen über
|
||||||
|
`git rev-parse --git-path info/exclude`) — wiederverwenden, nicht neu bauen.
|
||||||
|
- **An** → nichts tun, der Nutzer commitet den Ordner selbst.
|
||||||
|
|
||||||
|
### Worktree-Regeln (hart)
|
||||||
|
|
||||||
|
- **Geschrieben wird ausschließlich im Haupt-Checkout, nie in einem Worktree.** Sonst
|
||||||
|
entstehen `INDEX.md`-Merge-Konflikte über parallele Tasks — exakt das Problem, das oben
|
||||||
|
gegen den Graph-Ansatz spricht.
|
||||||
|
- **Gelesen wird über den absoluten Pfad auf den Haupt-Checkout.** Eine Regel für beide
|
||||||
|
Toggle-Stellungen; in „aus" existiert der Ordner im Worktree ohnehin nicht.
|
||||||
|
- Falls der Store getrackt ist und der Distiller committet: pfad-skopiert
|
||||||
|
(`git commit -- .claudedo`), nie blank — der Haupt-Checkout wird von parallelen
|
||||||
|
Sessions geteilt.
|
||||||
|
- Ein Finding je Datei heißt: parallele Schreiber kollidieren nicht. Umkämpft ist nur
|
||||||
|
`INDEX.md`; das Tool serialisiert dessen Rewrite prozessintern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Schreibweg — ein MCP-Tool
|
||||||
|
|
||||||
|
`save_finding(slug, title, body, scope = "", list = "")` am bestehenden `claudedo`-MCP-Server.
|
||||||
|
|
||||||
|
Signatur folgt den zwei test-erzwungenen Konventionen des Servers: jeder optionale
|
||||||
|
Parameter hat einen C#-Defaultwert, und das Tool gibt kein blankes `Task` und keine
|
||||||
|
nullable Payload zurück (→ `docs/explore-notes/external-mcp.md`). Beschreibungsstil nach
|
||||||
|
`External/McpToolDocs.cs`.
|
||||||
|
|
||||||
|
### Welcher Store wird beschrieben?
|
||||||
|
|
||||||
|
Der MCP-Server ist global, der Store projektgebunden — die Zuordnung muss explizit sein:
|
||||||
|
|
||||||
|
1. **Autonomer Run:** aus dem Aufrufkontext (`TaskRunMcpContext`) → Task → Liste →
|
||||||
|
`working_dir`. Der `list`-Parameter wird ignoriert.
|
||||||
|
2. **Interaktive Session ohne Task-Kontext:** `list` (Name oder Id) entscheidet. Fehlt er
|
||||||
|
und es existiert **genau eine** Liste, wird diese genommen; bei mehreren gibt das Tool
|
||||||
|
einen Fehler zurück, der die verfügbaren Listen nennt. Nie raten.
|
||||||
|
|
||||||
|
Geschrieben wird immer in `<list.working_dir>/.claudedo/` — also den Haupt-Checkout,
|
||||||
|
auch wenn der Aufrufer in einem Worktree läuft.
|
||||||
|
|
||||||
|
Bewusst ein **dummes Schreib-Tool**, kein „generiere mir Findings"-Tool: der Aufrufer hat
|
||||||
|
den Kontext bereits. Ein zweiter Claude-Lauf (RefineRunner-Bauart) würde ihn aus
|
||||||
|
Transkripten rekonstruieren — teurer und schlechter.
|
||||||
|
|
||||||
|
**Genau ein Tool.** Gelesen wird mit `Read` auf die Dateien; das kostet nichts und braucht
|
||||||
|
kein Tool. Der Server exponiert bereits ~60 Tools, eines mehr ist im Prefix Rauschen —
|
||||||
|
fünf wären es nicht.
|
||||||
|
|
||||||
|
**Verhalten:**
|
||||||
|
- Gleicher `slug` → **überschreiben**, nicht anlegen. Dedupe gehört ins Tool, nicht in die
|
||||||
|
Prompt-Disziplin.
|
||||||
|
- Schreibt `traps/<slug>.md` **und** aktualisiert die Zeile in `INDEX.md` atomar.
|
||||||
|
- `verified-against` wird vom Tool aus dem aktuellen HEAD des Haupt-Checkouts gesetzt,
|
||||||
|
nicht vom Aufrufer.
|
||||||
|
- `source-task` wird aus dem Aufrufkontext gesetzt, wenn vorhanden (autonome Runs),
|
||||||
|
sonst leer (interaktive Sessions).
|
||||||
|
|
||||||
|
### Aufrufwege
|
||||||
|
|
||||||
|
1. **Im Run.** Ein Task-Agent tritt in eine Falle und hält sie fest. Höchste Qualität —
|
||||||
|
er hat gerade Turns dafür verbrannt. Billigster Weg, kein zusätzlicher Prozess.
|
||||||
|
2. **Merge-Helper-Abschlussphase.** Der List-Handler sweept am Ende nach, was ihm über den
|
||||||
|
Batch hinweg auffiel. Er sieht Diffs, nicht das Straucheln — daher Ergänzung zu (1),
|
||||||
|
kein Ersatz.
|
||||||
|
3. **Interaktive Sessions.** Der `claudedo`-MCP ist global registriert, das Tool steht
|
||||||
|
dort ohne Weiteres zur Verfügung.
|
||||||
|
4. **VSCode.** Nutzer korrigiert und löscht direkt in den Dateien.
|
||||||
|
|
||||||
|
### Aufnahmehürde
|
||||||
|
|
||||||
|
Ein Finding ist nur, was **dauerhaft**, **nicht offensichtlich** und **verhaltensändernd**
|
||||||
|
ist: „X sieht aus wie Y, ist aber Z — mach stattdessen W". Ein gefixter Bug ist **kein**
|
||||||
|
Finding, das ist Git-Historie.
|
||||||
|
|
||||||
|
Diese Hürde steht in der Tool-Beschreibung. Sie ist kein Stilhinweis, sondern die
|
||||||
|
Betriebsbedingung: `INDEX.md` wird immer gelesen; bei ~15 tok je Zeile sind 50 Findings
|
||||||
|
≈ 750 tok, 300 Findings ≈ 4 500 tok — ab da kostet der Index so viel wie früher eine ganze
|
||||||
|
Note und die Ersparnis ist weg. Deshalb zusätzlich eine **Warnschwelle bei 80 Einträgen**,
|
||||||
|
die das Tool im Ergebnis zurückmeldet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Leseweg
|
||||||
|
|
||||||
|
ClaudeDo hängt einen Satz an den System-Prompt seiner eigenen Runs
|
||||||
|
(`--append-system-prompt`, wie heute schon für andere Zwecke genutzt):
|
||||||
|
|
||||||
|
> Findings-Index für dieses Projekt: `<abs>/.claudedo/INDEX.md`. Lies ihn, bevor du den
|
||||||
|
> Code erkundest, und öffne nur die Findings, die zu deiner Aufgabe passen.
|
||||||
|
|
||||||
|
~30 Token, komplett in ClaudeDo gekapselt, **keine CLAUDE.md wird angefasst** — weder die
|
||||||
|
des Projekts noch die globale.
|
||||||
|
|
||||||
|
Interaktive Sessions bekommen diesen Zeiger nicht (ClaudeDo startet sie nicht). Sie können
|
||||||
|
schreiben, aber nicht automatisch lesen. Wer das will, setzt selbst eine Zeile in seine
|
||||||
|
globale CLAUDE.md — außerhalb des Scopes dieses Features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI
|
||||||
|
|
||||||
|
Ein Button an der Liste: **„Findings öffnen"** → öffnet `<working-dir>/.claudedo/` im
|
||||||
|
Datei-Explorer bzw. der Standardanwendung. **Kein In-App-Editor** — Kuration passiert in
|
||||||
|
VSCode.
|
||||||
|
|
||||||
|
Dazu der Toggle aus §3 im Dialog „Liste anlegen" und in den Listen-Einstellungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scope-Grenze (bewusst akzeptiert)
|
||||||
|
|
||||||
|
Die Kapselung in ClaudeDo deckelt den Lese-Nutzen auf den Agent-Anteil: laut Messung
|
||||||
|
**18,4 %** des Verbrauchs (interaktiv = 81,6 %). Das ist kein Argument gegen das Feature —
|
||||||
|
Retries passieren ausschließlich auf der Agent-Seite, und dort sitzt der 11-Mio-Hebel.
|
||||||
|
Aber „Token-Ersparnis" heißt hier: Ersparnis in den 18 %, nicht auf dem Konto insgesamt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Nicht in v1
|
||||||
|
|
||||||
|
- **`maps/`** — Subsystem-Maps. Ordner reserviert, Inhalt später. Die bestehenden
|
||||||
|
`docs/explore-notes/` bleiben unangetastet.
|
||||||
|
- **Retro-Distill aus Transkripten.** Erst interessant, wenn belegt ist, dass Findings
|
||||||
|
überhaupt wirken. Rohmaterial bleibt verfügbar: vollständiges NDJSON je Run unter
|
||||||
|
`~/.todo-app/logs/{taskId}_run{N}.ndjson`, `ResultMarkdown` in `task_runs`,
|
||||||
|
`WorktreeEntity.BaseCommit/HeadCommit/MergeCommit` für den nachträglichen Diff.
|
||||||
|
- **Ernte der bestehenden `⚠️ Gotcha:`-Sektionen** aus `docs/explore-notes/` in den Store.
|
||||||
|
Naheliegender erster Füllstand, aber eine eigene, manuelle Aktion.
|
||||||
|
- **Wirkungsmessung** (welches Finding wurde je gelesen, hat es einen Retry verhindert).
|
||||||
|
Braucht ein eigenes Konzept; Dateien tragen keinen Zähler.
|
||||||
|
- **Automatischer Staleness-Check** gegen `verified-against`. Frontmatter trägt den Commit,
|
||||||
|
ausgewertet wird er in v1 nur vom Menschen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testbarkeit
|
||||||
|
|
||||||
|
- `save_finding`: Neuanlage, Überschreiben bei gleichem Slug, Index-Update, Slug-Validierung
|
||||||
|
(kein Pfad-Escape), Warnschwelle bei 80 Einträgen, paralleler Index-Rewrite.
|
||||||
|
- Store-Auflösung: Task-Kontext schlägt `list`; genau eine Liste ohne `list` → Treffer;
|
||||||
|
mehrere Listen ohne `list` → Fehler statt Raten; Schreibziel ist der Haupt-Checkout,
|
||||||
|
auch wenn der Aufrufer in einem Worktree sitzt.
|
||||||
|
- Die beiden `external-mcp`-Konventionen (Default je optionalem Parameter, kein blankes
|
||||||
|
`Task`/nullable Payload) sind test-erzwungen — die bestehenden Tests greifen automatisch.
|
||||||
|
- Toggle: `.git/info/exclude`-Zeile wird genau einmal geschrieben (Idempotenz) — die
|
||||||
|
bestehenden Tests zu `SessionSkillSeeder` sind die Vorlage.
|
||||||
|
- Kein Test darf die echte `claude`-CLI starten.
|
||||||
|
- `IWorkerClient` / `WorkerHub` / ViewModel-Konstruktoren ändern → handgeschriebene Fakes in
|
||||||
|
**beiden** Testprojekten mitziehen.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Dependency chain display — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-11
|
||||||
|
**Status:** approved, not implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`DependsOnTaskId` is invisible in the UI. `TaskRowViewModel` has no `DependsOnTaskId` property
|
||||||
|
and `TaskRowView.axaml` renders nothing for it — a chained task looks exactly like an unrelated
|
||||||
|
one. The execution order the user declared is not readable anywhere in the list.
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
A chain renders as a group: the head full width, its dependents indented behind a vertical rail,
|
||||||
|
each carrying a small circular step badge on the rail.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ chain head (full width, no badge) │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
╷ ┌────────────────────────────────────┐
|
||||||
|
(1)│ first dependent │
|
||||||
|
╷ └────────────────────────────────────┘
|
||||||
|
╷ ┌────────────────────────────────────┐
|
||||||
|
(2)│ second │
|
||||||
|
╷ └────────────────────────────────────┘
|
||||||
|
╷ ┌────────────────────────────────────┐
|
||||||
|
(2)│ parallel to the second │
|
||||||
|
╷ └────────────────────────────────────┘
|
||||||
|
╷ ┌────────────────────────────────────┐
|
||||||
|
(3)│ third │
|
||||||
|
└────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model reading
|
||||||
|
|
||||||
|
- **Step number = hop distance from the chain head**, not a running counter. `DependsOnTaskId` is
|
||||||
|
a single FK, so several tasks may share one predecessor → same depth → **same number**. Two
|
||||||
|
rows showing `2` means "these two are both unblocked by step 1", which is the intended reading.
|
||||||
|
- **Chain head** = a task with no `DependsOnTaskId` that at least one other task points at. A task
|
||||||
|
with no dependents and no dependency is not a chain and renders exactly as today.
|
||||||
|
- Cycles are impossible (`TaskStateService.SetDependsOnAsync` rejects them), so the depth walk
|
||||||
|
always terminates. Still cap the walk defensively.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
**One indent level only — parent wins.** The 24 px indent track already exists for planning
|
||||||
|
children (`TaskRowView.axaml:22-28`, gated on `ShowAsChild`). A planning child that *also* has a
|
||||||
|
`DependsOnTaskId` keeps the parent indent and shows its chain membership as a small inline
|
||||||
|
chip (`after #123`) instead of a second indent level. No nesting, no 48 px rows.
|
||||||
|
|
||||||
|
**The group is pulled together; the head carries it.** Chain members are re-ordered to sit
|
||||||
|
directly under their head regardless of `SortOrder`, so displayed order always equals execution
|
||||||
|
order. Dragging the head moves the whole group; members are not individually draggable.
|
||||||
|
|
||||||
|
**Head not in view → no orphan rails.** Exact precedent exists: `ParentInView` /`ShowAsChild`
|
||||||
|
(`TaskRowViewModel.cs:75`, computed in `Regroup` at `TasksIslandViewModel.cs:543`). Same
|
||||||
|
treatment — if the head is filtered out (other list, My Day, completed group), the row renders
|
||||||
|
flat with the `after #123` chip instead of a dangling rail.
|
||||||
|
|
||||||
|
**Badge has no `#`.** If task numbers (`2026-08-11-task-numbers-design.md`) also land, a row
|
||||||
|
would show a rail badge and a `#412` title prefix. The rail badge is a step position, not an
|
||||||
|
identity — render it bare (`2`), never `#2`.
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| # | Slice | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | VM: `DependsOnTaskId` + depth/head computation in `Regroup`, group pull-together, drag semantics | — |
|
||||||
|
| 2 | View: rail, step badge, `after …` chip, locale keys | 1 |
|
||||||
|
|
||||||
|
Slice 1 exposes the contract Slice 2 binds to: `ShowAsChainMember`, `ChainStep`,
|
||||||
|
`ChainAfterLabel`.
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Feedback für langlaufende Operationen
|
||||||
|
|
||||||
|
**Datum:** 2026-08-11
|
||||||
|
**Status:** Design freigegeben, Implementierung offen
|
||||||
|
**Auslöser:** Ein Merge über das Detail-Pane blockierte minutenlang ohne jede Anzeige.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Langlaufende Operationen geben kein Feedback. Der Nutzer klickt, nichts passiert sichtbar,
|
||||||
|
die App liest sich als abgestürzt. Der gemeldete Fall war ein Merge mit Verify-Gate, aber das
|
||||||
|
ist ein Symptom eines Musters: Feedback wird **pro Fall ad-hoc** gebaut
|
||||||
|
(`PrepStarted/Line/Finished`, `RefineStarted/Finished`, `PlanningMerge*`, `MergeProgress`),
|
||||||
|
und dabei geht regelmäßig eine Hälfte verloren oder eine Fläche wird vergessen.
|
||||||
|
|
||||||
|
Belegend: `HubBroadcaster.MergeProgress` existierte bereits, während `WorkerClient` noch
|
||||||
|
keinen Listener hatte — Sender ohne Empfänger. Das wurde parallel zu diesem Design in einer
|
||||||
|
anderen Session nachgezogen (siehe *Abhängigkeit zur Parallelarbeit*), deckt aber nur das
|
||||||
|
MergeModal ab, nicht den Approve-Pfad im Detail-Pane.
|
||||||
|
|
||||||
|
## Vier Fehlerbilder
|
||||||
|
|
||||||
|
Die Analyse trennt vier Klassen, die **verschiedene** Lösungen brauchen. Sie zusammen als
|
||||||
|
„fehlendes Feedback" zu behandeln war der Fehler der bisherigen Einzelfall-Fixes.
|
||||||
|
|
||||||
|
### Bild 1 — Stiller Await im UI
|
||||||
|
|
||||||
|
Ein `[RelayCommand]` awaitet einen Call, ohne Busy-State, ohne Anzeige. Der Button bleibt
|
||||||
|
klickbar (Doppelklick-Risiko), es gibt keinen Hinweis, dass etwas läuft.
|
||||||
|
|
||||||
|
| Stelle | Befund |
|
||||||
|
|---|---|
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:1206` `ApproveReviewAsync` | **Der gemeldete Fall.** Kein Busy-Flag, kein Indikator; das Verify-Gate kann den Call Minuten halten |
|
||||||
|
| `DetailsIslandViewModel.cs:1244` `SubmitForReviewAsync` | kein Busy-Flag (`MissionControlViewModel` hat für denselben Call ein `IsSubmitPending`, das Detail-Pane nicht) |
|
||||||
|
| `DetailsIslandViewModel.cs` `RejectReviewAsync`, `ParkReviewAsync` | kein Busy-Flag; Fehler werden verschluckt (`catch { return; }`) |
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs:121` `PreviewMergeAsync` | kein Indikator |
|
||||||
|
| `WorktreesOverviewModalViewModel` (`IsBusy` vorhanden) | `IsBusy` schaltet in `WorktreesOverviewModalView.axaml:107-108` **nur** Buttons aus — kein Spinner, kein Text. Betrifft Cleanup, Reset, ForceRemove, Refresh, Batch-Merge |
|
||||||
|
| Settings-Tabs: `SessionSkillsSettingsTabViewModel`, `FilesSettingsTabViewModel`, `OnlineInboxSettingsViewModel` | dito — `IsEnabled="{Binding !IsBusy}"`, keine Anzeige. Skill-Install ist ein `git clone` |
|
||||||
|
| `WeeklyReportModalViewModel`, `RepoImportModalViewModel` | `IsBusy` nur als CanExecute-Gate |
|
||||||
|
| Islands ↔ SQLite direkt | Die Islands sprechen direkt mit der DB (`new TaskRepository(ctx)` u.ä., ~15 Stellen allein in `DetailsIslandViewModel`). Meist schnell; `ClearCompleted`, Drag-Reorder über viele Zeilen und das Laden großer Listen nicht zwingend |
|
||||||
|
|
||||||
|
Bereits sauber gelöst und **nicht** anzufassen: `MergeModalViewModel` (Spinner + Phase +
|
||||||
|
Elapsed), `ConflictResolverViewModel` (`IsBusy` + Text), `DiffViewerViewModel.IsLoadingCombined`,
|
||||||
|
`UsageMonitorModalViewModel` (Spinner).
|
||||||
|
|
||||||
|
### Bild 2 — UI-Thread-Freeze
|
||||||
|
|
||||||
|
Eine andere Fehlerklasse: hier hilft kein Indikator, weil der Dispatcher blockiert ist und
|
||||||
|
der Spinner nicht gezeichnet werden könnte. Erst auslagern, dann anzeigen.
|
||||||
|
|
||||||
|
| Stelle | Befund |
|
||||||
|
|---|---|
|
||||||
|
| `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs:182` und `:260` | `UnifiedDiffParser.Parse(raw)` läuft **synchron auf dem UI-Thread** |
|
||||||
|
| `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs:102` | `DiffAlignment.Build(File?.Lines)` ebenfalls |
|
||||||
|
|
||||||
|
Im gesamten `ClaudeDo.Ui`-Projekt gibt es **zwei** `Task.Run`-Offloads (`RepoScanner`,
|
||||||
|
`RestartWorkerService`). CPU-Arbeit auf dem UI-Thread ist damit die Norm, nicht die Ausnahme.
|
||||||
|
|
||||||
|
### Bild 3 — Worker-Stille
|
||||||
|
|
||||||
|
Operationen ohne UI-Auslöser. Kein Client erfährt, dass sie laufen; die UI zeigt im
|
||||||
|
Startfall nur „reconnecting".
|
||||||
|
|
||||||
|
| Stelle | Warum langsam |
|
||||||
|
|---|---|
|
||||||
|
| 6× `src/ClaudeDo.Worker/Lifecycle/*Recovery.cs` beim Worker-Start (`OrphanRecovery`, `StaleTaskRecovery`, `PromptFileRecovery`, `AttachmentOrphanRecovery`, `PlanningLineageRecovery`, `LegacyWorktreeFolderRecovery`) | git + DB über alle Tasks/Worktrees |
|
||||||
|
| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Worktree-Anlage beim Task-Start | `git worktree add` + Branch; stille Lücke zwischen `Queued` und erster Ausgabe |
|
||||||
|
| `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` | Hintergrund-git über alle Worktrees |
|
||||||
|
| `TaskMergeService.RebaseOthersAfterMergeAsync` | läuft **innerhalb** des Merge-Calls, **nach** dem Phasen-Broadcast — vollständig unsichtbar |
|
||||||
|
| `PlanningAggregator`, `PlanningMergeOrchestrator` | ein Merge pro Subtask |
|
||||||
|
| `OnlineSyncService`, `UsageMonitorService`, `PrimeScheduler`, `QueueService` | periodisch, Netz/git |
|
||||||
|
|
||||||
|
Querverweis: `docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md`
|
||||||
|
führt `WorktreeManager.cs:103` als DB-Write-ohne-Broadcast (Loch 3). C3 muss prüfen, ob das
|
||||||
|
inzwischen geschlossen ist, statt es doppelt zu beheben.
|
||||||
|
|
||||||
|
### Bild 4 — MCP-Stille
|
||||||
|
|
||||||
|
Trifft nicht die UI, sondern Agenten-Runs. Nur drei Dateien reporten Progress:
|
||||||
|
`ExternalMcpService` (5 Tools), `TaskWaitMcpTools`, `TaskMergeService`. Ohne Progress laufen
|
||||||
|
u.a. die 8 `batch_*`-Tools, `cleanup_task_worktree`, `get_task_diff`, `preview_merge_set`.
|
||||||
|
|
||||||
|
Das ist die dokumentierte Ursache des Traps „MCP 300s idle abort leaves merges stuck": der
|
||||||
|
Client bricht bei Stille ab, während der Worker weiterarbeitet, und der Task bleibt hängen.
|
||||||
|
|
||||||
|
### Bewusst ausgeschlossen: der Installer
|
||||||
|
|
||||||
|
`ClaudeDo.Installer` hat eine vollständige `IProgress<string>`-Step-Pipeline mit Live-Anzeige
|
||||||
|
(`Core/InstallerService.cs`, `Steps/*`, `Pages/InstallPage`). Das ist die beste Progress-Fläche
|
||||||
|
im Repo und braucht nichts.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. UI-Primitive: `OperationStatus`
|
||||||
|
|
||||||
|
Eine `ObservableObject`-Klasse, die ein ViewModel als Property exponiert. Mehrere pro VM sind
|
||||||
|
erlaubt und erwünscht — `WorktreesOverviewModalViewModel` braucht getrennte für Refresh,
|
||||||
|
Cleanup und Merge, sonst blockiert ein laufender Refresh die Cleanup-Anzeige.
|
||||||
|
|
||||||
|
| Property | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `IsRunning` | sofort `true` → treibt `CanExecute`, verhindert Doppelklick |
|
||||||
|
| `ShowIndicator` | erst nach **300 ms** `true` → kein Flackern bei schnellen Calls |
|
||||||
|
| `Label` | lokalisierter Text, **mid-flight überschreibbar** (`Report(label)`) |
|
||||||
|
| `Elapsed` | `mm:ss`, **lokal getickt** |
|
||||||
|
| `IsStalled` | `true`, wenn **60 s ohne Aktualisierung** vergangen sind → Hinweis „läuft weiter, Worker antwortet noch" |
|
||||||
|
|
||||||
|
`IsStalled` bemisst sich an der Zeit seit dem letzten `Report`, **nicht** an der Gesamtdauer.
|
||||||
|
Sonst würde ein regulär mehrminütiges Verify-Gate fälschlich als hängend gemeldet. Eine
|
||||||
|
Operation ohne jeden `Report` gilt nach 60 s als stalled — das ist der Fall, der heute wie ein
|
||||||
|
Absturz aussieht.
|
||||||
|
|
||||||
|
Benutzung:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
|
||||||
|
var result = await _worker.ApproveReviewAsync(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
`Dispose` beendet die Operation auch im Exception-Fall.
|
||||||
|
|
||||||
|
**Zeitquelle ist injizierbar** (`TimeProvider`, in .NET 8 vorhanden), kein statischer
|
||||||
|
`DispatcherTimer`. Ein geteilter statischer Timer wäre genau die Sorte Shared State, die die
|
||||||
|
`Ui.Tests` reihenfolgen-abhängig flaky macht. Timer-Callbacks kommen vom Threadpool und müssen
|
||||||
|
auf den Dispatcher gepostet werden.
|
||||||
|
|
||||||
|
**Fehlerbehandlung bleibt unverändert.** Weiter über `ShowErrorAsync` / `ErrorReported` /
|
||||||
|
`FlashFooterError`. `OperationStatus` transportiert keine Fehler.
|
||||||
|
|
||||||
|
### 2. UI-Control: `OperationIndicator`
|
||||||
|
|
||||||
|
`src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml` — Spinner (`Ellipse.spinner` aus
|
||||||
|
`IslandStyles`) + Label + Elapsed, gebunden an eine `OperationStatus`. Ohne das Control wird
|
||||||
|
die Spinner-StackPanel aus `MergeModalView.axaml:28-30` acht Mal von Hand nachgebaut.
|
||||||
|
|
||||||
|
Alle Texte über Locale-Keys im Namespace `ops.*`; en/de-Parität erzwingen die
|
||||||
|
`Localization.Tests`.
|
||||||
|
|
||||||
|
### 3. Worker-Kanal: ein generisches Progress-Event
|
||||||
|
|
||||||
|
Der Worker sendet nur, was das UI **nicht erraten kann** — interne Phasen und Zählstände. Beim
|
||||||
|
Klick weiß das UI selbst, was es angestoßen hat.
|
||||||
|
|
||||||
|
```
|
||||||
|
OperationProgress(string opKey, string phase, int current, int total)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
|
||||||
|
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`)
|
||||||
|
- **Kein Elapsed auf der Leitung** — das tickt das UI
|
||||||
|
- `current`/`total` erlaubt echten Fortschritt („Worktree 3/12"), was das heutige
|
||||||
|
`MergeProgress` nicht kann
|
||||||
|
|
||||||
|
Dieses Event **ersetzt** `MergeProgress`. Begründung, dass sich die Verallgemeinerung lohnt:
|
||||||
|
es gibt drei Produzenten (Merge-Phasen, Worktree-Cleanup, Planning-Integration), nicht einen.
|
||||||
|
|
||||||
|
**Aber `IWorkerClient.MergeProgressEvent` bleibt als dünner Forwarder bestehen.** Sonst
|
||||||
|
zerstört C1 die Gruppen-Isolation: A1 abonniert dieses Event, und ein Rename würde C zwingen,
|
||||||
|
`DetailsIslandViewModel` zu ändern — eine Datei, die Gruppe A besitzt. Der Forwarder kostet
|
||||||
|
vier Zeilen und hält die vier Sessions unabhängig. Er darf später entfernt werden, wenn A und
|
||||||
|
C beide gemergt sind.
|
||||||
|
|
||||||
|
### 4. MCP: `ProgressReporter` als eigene Klasse
|
||||||
|
|
||||||
|
`TaskMergeService.RunReportingProgressAsync` (Zeile ~175) ist die einzige existierende
|
||||||
|
Progress-Schleife. Sie wird als eigenständige Klasse extrahiert, sodass jedes MCP-Tool sie
|
||||||
|
nutzen kann, statt die Schleife zu kopieren. Zusätzlich ein Overload für Element-Fortschritt
|
||||||
|
(`i/n`) für die `batch_*`-Tools.
|
||||||
|
|
||||||
|
## Festgelegte Grenzen
|
||||||
|
|
||||||
|
Bewusst **nicht** Teil dieses Designs:
|
||||||
|
|
||||||
|
- **Kein Cancel.** Nur Sichtbarkeit. Ein Abbruch wäre bei einem Merge ohnehin irreführend: das
|
||||||
|
Verify-Gate läuft, wenn der Merge-Commit längst geschrieben ist — „abbrechen" könnte nur
|
||||||
|
*aufhören zu warten* bedeuten, nicht zurückrollen. Ohne Cancel entfällt der
|
||||||
|
`opId`-Handshake; die Korrelation läuft über `taskId` bzw. das offene Modal.
|
||||||
|
- **Keine Footer-Anzeige für laufende Operationen.** Anzeige am auslösenden Control und an der
|
||||||
|
Task-Zeile / im Detail-Pane. Der Footer bleibt für Fehler (`FlashFooterError`) und den
|
||||||
|
Worker-Log.
|
||||||
|
- **Keine Prozent-Balken.** Die meisten Operationen haben kein sinnvolles Total.
|
||||||
|
`current/total` wird als Text gezeigt, nicht als Balken.
|
||||||
|
- **Keine EF-Migration.** Damit entfällt der Trap paralleler Migrationen.
|
||||||
|
|
||||||
|
## Messung
|
||||||
|
|
||||||
|
„Erst messen, dann instrumentieren" gilt für Bild 1 und 3 — aber an **einer** Stelle, nicht an
|
||||||
|
zwanzig:
|
||||||
|
|
||||||
|
1. Ein Timing-Hook in `WorkerClient` loggt jeden Hub-Invoke mit Dauer.
|
||||||
|
2. Ein zweiter im DB-Pfad der Islands.
|
||||||
|
|
||||||
|
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. A5 und C entscheiden danach,
|
||||||
|
statt zu raten. Bild 2 und 4 brauchen keine Messung — der Befund ist aus dem Code eindeutig.
|
||||||
|
|
||||||
|
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
|
||||||
|
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
|
||||||
|
|
||||||
|
## Parallelisierung: vier Merge-Helper-Gruppen
|
||||||
|
|
||||||
|
Der Zuschnitt folgt dem **Datei-Eigentum**, nicht der Fachlichkeit — nur so können vier
|
||||||
|
Sessions gleichzeitig laufen, ohne sich zu überschreiben.
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 (Fundament, muss allein zuerst landen)
|
||||||
|
├── Gruppe A Bild 1: UI-Stille (5 Pakete)
|
||||||
|
└── Gruppe B Bild 2: UI-Freeze (3 Pakete)
|
||||||
|
|
||||||
|
Gruppe D Bild 4: MCP (4 Pakete) ← braucht P0 NICHT, kann sofort starten
|
||||||
|
Gruppe C Bild 3: Worker (5 Pakete) ← nach P0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Gruppe | Exklusiv besessene Dateien |
|
||||||
|
|---|---|
|
||||||
|
| **P0** | `OperationStatus.cs`, `OperationIndicator.axaml`, **beide `locales/*.json`** |
|
||||||
|
| **A** | Island-VMs, Modal-VMs und deren AXAML |
|
||||||
|
| **B** | `DiffViewerViewModel.cs`, `DiffTextView.axaml.cs` |
|
||||||
|
| **C** | `HubBroadcaster`, `WorkerHub`, `WorkerClient`, `IWorkerClient`, `StubWorkerClient`, `Lifecycle/*Recovery`, `WorktreeManager` |
|
||||||
|
| **D** | `Worker/External/*McpTools`, `ExternalMcpService` |
|
||||||
|
|
||||||
|
**Der einzige echte Konfliktkandidat sind die Locale-Dateien.** A und C brauchen Keys — D
|
||||||
|
nicht: MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
|
||||||
|
Klartext-Strings ohne Locale-Eintrag.
|
||||||
|
|
||||||
|
Regel: **P0 legt die Keys für A und C vorab an**, danach fasst keine Gruppe die JSONs mehr an.
|
||||||
|
Braucht eine Gruppe doch einen nicht vorgesehenen Key, hängt sie ihn als **letzte** Änderung
|
||||||
|
des Pakets an und behandelt ihn im Merge-Helper als bekannte Konfliktstelle.
|
||||||
|
|
||||||
|
Drei erzwungene Serialisierungen:
|
||||||
|
- `TaskMergeService` — die Parallelsession arbeitet dort; C1 danach.
|
||||||
|
- `WorkerClient.cs` — P0-2 setzt dort den Timing-Hook, C ändert dieselbe Datei. P0 liegt
|
||||||
|
ohnehin vor C, damit unkritisch — aber C darf nicht vor P0 starten.
|
||||||
|
- Innerhalb jeder Gruppe `serializeOnFileOverlap` auf der Liste setzen.
|
||||||
|
|
||||||
|
Ergänzung zum Datei-Eigentum: `Services/UpdateCheckService.cs` gehört zu **A** (Paket A3),
|
||||||
|
`Services/WorkerClient.cs` zu **C**. Beide liegen im selben Ordner, sind aber verschiedene
|
||||||
|
Dateien.
|
||||||
|
|
||||||
|
### Abhängigkeit zur Parallelarbeit
|
||||||
|
|
||||||
|
Während dieses Designs hat eine andere Session im gemeinsamen `main`-Checkout das
|
||||||
|
Merge-Feedback für das **MergeModal** implementiert — inzwischen committet als
|
||||||
|
`cad0582 fix(merge): conventional merge-commit default and live verify progress`:
|
||||||
|
Phasen-Broadcast in `TaskMergeService`,
|
||||||
|
`HubBroadcaster.MergeProgress`, `IWorkerClient.MergeProgressEvent`,
|
||||||
|
`MergeModalViewModel.ProgressMessage`, Spinner in `MergeModalView.axaml`, plus Tests.
|
||||||
|
|
||||||
|
Konsequenzen:
|
||||||
|
- Der Merge-Fall im MergeModal ist **Vorarbeit**, nicht neu zu bauen.
|
||||||
|
- Zwei Nachbesserungen bleiben: A1 schließt den Approve-Pfad im Detail-Pane an, C1 stellt
|
||||||
|
Elapsed auf UI-lokalen Tick um (die Implementierung lässt den Worker alle 30 s ticken —
|
||||||
|
die ersten 30 Sekunden zeigt sie nur „merging").
|
||||||
|
- Der Blocker ist damit aufgelöst: C hängt nur noch an P0, nicht mehr an der Parallelsession.
|
||||||
|
|
||||||
|
## Pakete
|
||||||
|
|
||||||
|
### P0 — Fundament
|
||||||
|
|
||||||
|
| # | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| P0-1 | `OperationStatus` + `OperationIndicator` + `ops.*`-Keys **für alle vier Gruppen vorab** + `Ui.Tests` mit Fake-`TimeProvider` |
|
||||||
|
| P0-2 | Timing-Hook in `WorkerClient` + im DB-Pfad der Islands |
|
||||||
|
| P0-3 | Regel in `src/ClaudeDo.Ui/CLAUDE.md` und `src/ClaudeDo.Worker/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch eine `OperationStatus`; MCP-Tools über ~5 s reporten Progress |
|
||||||
|
|
||||||
|
### Gruppe A — Bild 1: UI-Stille
|
||||||
|
|
||||||
|
| # | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| A1 | Detail-Pane: Approve & Merge, Submit for review, Reject, Park, Preview-Merge. Abonniert das Merge-Phasen-Event zum Nachschärfen des Labels |
|
||||||
|
| A2 | WorktreesOverview: Refresh, Cleanup, Reset, ForceRemove, Batch-Merge (inkl. Zeilen-Status) |
|
||||||
|
| A3 | Settings-Tabs: SessionSkill install/update, Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check |
|
||||||
|
| A4 | Reports und Planning-UI: GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate, Finalize/QueuePlanningSubtasks |
|
||||||
|
| A5 | Island-DB-Pfade — **nur die, die P0-2 als Ausreißer zeigt** |
|
||||||
|
|
||||||
|
### Gruppe B — Bild 2: UI-Freeze
|
||||||
|
|
||||||
|
| # | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| B1 | `UnifiedDiffParser.Parse` nach `Task.Run` + Indikator im DiffViewer |
|
||||||
|
| B2 | `DiffAlignment.Build`: Grenze definieren (ab N Zeilen auslagern oder inkrementell aufbauen) |
|
||||||
|
| B3 | Guard-Test: Parse/Build über ein großes Fixture blockiert den Dispatcher nicht |
|
||||||
|
|
||||||
|
### Gruppe C — Bild 3: Worker-Stille
|
||||||
|
|
||||||
|
| # | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| C1 | `OperationProgress(opKey, phase, current, total)` ersetzt `MergeProgress`; Elapsed auf UI-lokalen Tick |
|
||||||
|
| C2 | Startup-Recovery sichtbar (6 Services) — Anzeige im Shell statt nur „reconnecting" |
|
||||||
|
| C3 | Worktree-Anlage beim Task-Start sichtbar an der Task-Zeile. Vorher prüfen, ob der Broadcast-Gap aus dem 08-07-Spec schon geschlossen ist |
|
||||||
|
| C4 | `RebaseOthersAfterMergeAsync` und `WorktreeMaintenanceService` in den Kanal |
|
||||||
|
| C5 | Periodische Dienste (Usage, OnlineSync, Prime, Queue): nur Aktivität und Fehler, kein Tick-Spam |
|
||||||
|
|
||||||
|
### Gruppe D — Bild 4: MCP
|
||||||
|
|
||||||
|
| # | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| D1 | `ProgressReporter` aus `TaskMergeService.RunReportingProgressAsync` extrahieren, inkl. `i/n`-Overload |
|
||||||
|
| D2 | Die 8 `batch_*`-Tools mit Element-Fortschritt |
|
||||||
|
| D3 | Worktree- und Diff-Tools: `cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set` |
|
||||||
|
| D4 | Restliche Long-Runner + Doku-Regel im Worker-`CLAUDE.md` |
|
||||||
|
|
||||||
|
## Test-Strategie
|
||||||
|
|
||||||
|
- **`OperationStatus`**: reine Unit-Tests mit Fake-`TimeProvider` — 300-ms-Grace, 60-s-Stall,
|
||||||
|
Elapsed-Formatierung, `Dispose` im Exception-Fall, `Report` überschreibt Label.
|
||||||
|
- **VM-Tests** (`Ui.Tests`, headless): Command setzt `IsRunning`, `CanExecute` sperrt während
|
||||||
|
des Laufs, Zustand ist nach einer Exception zurückgesetzt. Fakes: `StubWorkerClient` muss
|
||||||
|
bei jedem neuen Event mitwachsen.
|
||||||
|
- **B3** ist der einzige Test, der Blockierung prüft: großes Diff-Fixture, Messung, dass der
|
||||||
|
Dispatcher weiter Nachrichten verarbeitet.
|
||||||
|
- **Worker/MCP** (`Worker.Tests`): Progress-Callbacks werden mit einem Fake-`IProgress`
|
||||||
|
gezählt; keine echte Claude-CLI, keine echten Timeouts.
|
||||||
|
- **Visuell**: jedes Paket in A und B hat eine offene visuelle Prüfung. Spinner, Grace-Periode
|
||||||
|
und Layout kann kein Test bestätigen — das läuft über den Nutzer.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Task Numbers (`#123`) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-08-11
|
||||||
|
**Status:** approved, not implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Tasks are only addressable by GUID. Every report from Claude reads
|
||||||
|
"task `0ef5ga…` is done", which is unreadable and untraceable for the user. A short,
|
||||||
|
stable, human-speakable handle is needed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a **global, monotonically increasing integer** `TaskEntity.Number`, displayed as `#123`.
|
||||||
|
|
||||||
|
- **Global, not per list.** Per-list numbering would make `#123` ambiguous across the ~15
|
||||||
|
lists and would force a list argument into every lookup. Global costs nothing and is
|
||||||
|
unambiguous.
|
||||||
|
- **Alias, not identity.** The GUID stays the primary key and stays in branch names
|
||||||
|
(`claudedo/{id}`), worktree paths, and the `ClaudeDo-Task:` commit trailer. `Number` is
|
||||||
|
purely a display + lookup alias.
|
||||||
|
- **Immutable, never reused.** A number that appeared in a log must never later point at a
|
||||||
|
different task. Deleting tasks leaves gaps — that is correct and intended.
|
||||||
|
|
||||||
|
## Allocation
|
||||||
|
|
||||||
|
`MAX(number) + 1` is **wrong**: deleting the newest task frees its number for reuse.
|
||||||
|
Use a persistent counter instead.
|
||||||
|
|
||||||
|
- `app_settings.next_task_number` (INTEGER NOT NULL, singleton row — same table as
|
||||||
|
`MaxTurnsCeiling` etc.).
|
||||||
|
- Allocation is one statement, atomic under SQLite's single-writer model:
|
||||||
|
`UPDATE app_settings SET next_task_number = next_task_number + 1
|
||||||
|
WHERE id = 1 RETURNING next_task_number - 1`
|
||||||
|
executed in the **same transaction** as the task insert.
|
||||||
|
- `tasks.number` gets a **UNIQUE index** (`idx_tasks_number`) so a collision is a DB error,
|
||||||
|
not silent corruption. On a unique violation, retry the allocation (bounded, 5 attempts).
|
||||||
|
|
||||||
|
Only **two** insert sites exist and both must allocate — every other creation path
|
||||||
|
(UI, `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler task,
|
||||||
|
planning children) routes through one of them:
|
||||||
|
|
||||||
|
- `TaskRepository.AddAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:20`)
|
||||||
|
- `TaskRepository.AddChildAsync` (same file, ~line 297)
|
||||||
|
|
||||||
|
Do **not** reuse `SortOrder` — it is per list and mutable via drag & drop.
|
||||||
|
|
||||||
|
## Migration + backfill
|
||||||
|
|
||||||
|
One migration, created **alone** (never in parallel with another migration — sibling
|
||||||
|
migrations off the same parent silently drop each other's columns on a SQLite table rebuild):
|
||||||
|
|
||||||
|
1. `ALTER TABLE tasks ADD COLUMN number INTEGER NOT NULL DEFAULT 0`
|
||||||
|
2. Backfill in creation order:
|
||||||
|
`UPDATE tasks SET number = (SELECT COUNT(*) FROM tasks t2
|
||||||
|
WHERE t2.created_at < tasks.created_at OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id))`
|
||||||
|
(or the equivalent `ROW_NUMBER()` window function).
|
||||||
|
3. `ALTER TABLE app_settings ADD COLUMN next_task_number INTEGER NOT NULL DEFAULT 1`
|
||||||
|
then set it to `(SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)`.
|
||||||
|
4. Create the unique index **after** the backfill.
|
||||||
|
|
||||||
|
⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a test that
|
||||||
|
explicitly runs `Migrate()` against a DB seeded with pre-migration rows.
|
||||||
|
|
||||||
|
## Resolution: `#123` → GUID
|
||||||
|
|
||||||
|
New `TaskIdResolver` in `src/ClaudeDo.Worker/External/`:
|
||||||
|
|
||||||
|
- Input `#123` or bare `123` (a GUID is never all-digits, so this is unambiguous) → look up
|
||||||
|
by number.
|
||||||
|
- Anything else → passed through as a GUID.
|
||||||
|
- Unknown number → a clear MCP error (`no task with number 123`), never a silent null.
|
||||||
|
|
||||||
|
Wired at the top of every MCP tool taking a task id (~44 parameters across `External/`),
|
||||||
|
including the id arrays of the `batch_*` tools.
|
||||||
|
|
||||||
|
## Output surface
|
||||||
|
|
||||||
|
Two central mappers cover most tools:
|
||||||
|
|
||||||
|
- `ExternalMcpService.ToDto` (line ~1629) → `TaskDto`
|
||||||
|
- `ExternalMcpService.GetTaskRefAsync` (line ~335) → `TaskRefDto`
|
||||||
|
|
||||||
|
Add `Number` to both. Then the DTOs that carry a bare task id and bypass those mappers:
|
||||||
|
`RunTaskNowResult`, `MergeTaskResultDto`, `PossibleDuplicateDto`, `SubsetRelationDto`,
|
||||||
|
`FileOverlapDto`, the `BatchMcpTools` results, `TaskWaitMcpTools`, `QueueStateMcpTools`,
|
||||||
|
`RunHistoryMcpTools`.
|
||||||
|
|
||||||
|
`McpToolDocs` gets a shared clause instructing the agent to **refer to tasks as `#<number>`
|
||||||
|
when reporting to the user**. Without this the number is present in the payload but never
|
||||||
|
spoken — this clause is the actual point of the feature.
|
||||||
|
|
||||||
|
## UI surface
|
||||||
|
|
||||||
|
- `TaskRowViewModel.Number` → dim `#123` in the row, tokenized (no inline literals).
|
||||||
|
- Detail pane header.
|
||||||
|
- `HubBroadcaster.WorkerLog` business events in `TaskRunner` / `TaskMergeService` /
|
||||||
|
`TaskResetService` say `#123` instead of the GUID.
|
||||||
|
|
||||||
|
## Out of scope (possible follow-up)
|
||||||
|
|
||||||
|
Branch names and worktree folder names (`claudedo/123-<slug>`, `123-<slug>`). Cheap once
|
||||||
|
numbers exist, but a separate change — it touches merge, self-heal, and the merge helper.
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| # | Slice | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Schema, counter, allocator, backfill migration + tests | — |
|
||||||
|
| 2 | MCP output: `Number` in all task-carrying DTOs | 1 |
|
||||||
|
| 3 | MCP input: `TaskIdResolver` + `McpToolDocs` reporting clause | 2 |
|
||||||
|
| 4 | UI: row/detail display + worker-log messages | 1 |
|
||||||
|
| 5 | Docs: `CLAUDE.md` (Data, Worker), explore-notes | 3, 4 |
|
||||||
|
|
||||||
|
2 and 3 both edit `ExternalMcpService.cs` heavily, so 3 waits for 2 rather than running
|
||||||
|
beside it. 4 touches disjoint files and can run beside 2.
|
||||||
@@ -3,7 +3,9 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
using ClaudeDo.Ui;
|
using ClaudeDo.Ui;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels;
|
using ClaudeDo.Ui.ViewModels;
|
||||||
using ClaudeDo.Ui.Views;
|
using ClaudeDo.Ui.Views;
|
||||||
@@ -40,9 +42,22 @@ public partial class App : Application
|
|||||||
// modeless Mission Control window is still open.
|
// modeless Mission Control window is still open.
|
||||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||||
|
|
||||||
|
var shell = services.GetRequiredService<IslandsShellViewModel>();
|
||||||
|
|
||||||
|
// Last-resort backstop: an exception escaping a dispatcher job kills the process, and
|
||||||
|
// ClaudeDo hosts third-party UI (the ConPTY terminal control) whose async void key and
|
||||||
|
// render handlers have done exactly that — one bad keystroke took the whole app down,
|
||||||
|
// losing every open session. Swallowing is the lesser evil here: the failing job is
|
||||||
|
// already dead either way, and the user still gets told via the footer error strip.
|
||||||
|
Dispatcher.UIThread.UnhandledException += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
|
||||||
|
};
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
{
|
{
|
||||||
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
|
DataContext = shell,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
|
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
|
||||||
|
|||||||
@@ -52,8 +52,14 @@ sealed class Program
|
|||||||
// Dispose the container so WorkerClient.DisposeAsync runs —
|
// Dispose the container so WorkerClient.DisposeAsync runs —
|
||||||
// cancels the retry loop and closes the SignalR connection cleanly
|
// cancels the retry loop and closes the SignalR connection cleanly
|
||||||
// instead of abandoning it.
|
// instead of abandoning it.
|
||||||
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
try
|
||||||
catch { /* best effort on shutdown */ }
|
{
|
||||||
|
services.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* best effort on shutdown */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +108,9 @@ sealed class Program
|
|||||||
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
|
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
|
||||||
|
|
||||||
// Release check + installer update
|
// Release check + installer update
|
||||||
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
|
sc.AddSingleton<HttpClient>(_ =>
|
||||||
|
new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||||
|
{ Timeout = TimeSpan.FromSeconds(10) });
|
||||||
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
|
||||||
sc.AddSingleton<InstallerLocator>();
|
sc.AddSingleton<InstallerLocator>();
|
||||||
sc.AddSingleton<WorkerLocator>();
|
sc.AddSingleton<WorkerLocator>();
|
||||||
@@ -111,7 +119,9 @@ sealed class Program
|
|||||||
var releases = sp.GetRequiredService<IReleaseClient>();
|
var releases = sp.GetRequiredService<IReleaseClient>();
|
||||||
var informational = Assembly.GetEntryAssembly()?
|
var informational = Assembly.GetEntryAssembly()?
|
||||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||||
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
|
// Strip MinVer build metadata ("+sha") only — keep the prerelease suffix
|
||||||
|
// (e.g. "-alpha.0.14"), VersionComparer needs it to tell a dev build on
|
||||||
|
// main apart from a tagged release of the same numeric core.
|
||||||
var version = (informational ?? "0.0.0").Split('+')[0];
|
var version = (informational ?? "0.0.0").Split('+')[0];
|
||||||
return new UpdateCheckService(releases, version);
|
return new UpdateCheckService(releases, version);
|
||||||
});
|
});
|
||||||
@@ -124,7 +134,8 @@ sealed class Program
|
|||||||
sc.AddTransient<DiffViewerViewModel>();
|
sc.AddTransient<DiffViewerViewModel>();
|
||||||
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
||||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp =>
|
||||||
|
() => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||||
sc.AddTransient<MergeHelperSelectionModalViewModel>();
|
sc.AddTransient<MergeHelperSelectionModalViewModel>();
|
||||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||||
@@ -137,9 +148,11 @@ sealed class Program
|
|||||||
sc.AddTransient<RepoImportModalViewModel>();
|
sc.AddTransient<RepoImportModalViewModel>();
|
||||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||||
sc.AddTransient<WeeklyReportModalViewModel>();
|
sc.AddTransient<WeeklyReportModalViewModel>();
|
||||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp =>
|
||||||
|
() => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||||
sc.AddTransient<UsageMonitorModalViewModel>();
|
sc.AddTransient<UsageMonitorModalViewModel>();
|
||||||
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp => () => sp.GetRequiredService<UsageMonitorModalViewModel>());
|
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp =>
|
||||||
|
() => sp.GetRequiredService<UsageMonitorModalViewModel>());
|
||||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
sp.GetRequiredService<IWorkerClient>(), taskId));
|
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId.
|
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, Number (global immutable human-readable alias).
|
||||||
- Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
|
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
|
||||||
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||||
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||||
|
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). ⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a migration that runs `Migrate()` explicitly (not tested by default; see `worker-task-pipeline` notes).
|
||||||
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
|
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill 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), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
|
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
|
||||||
@@ -29,10 +30,11 @@ Beyond the basics it carries:
|
|||||||
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
|
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
|
||||||
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
|
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
|
||||||
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
|
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
|
||||||
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
|
| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
|
||||||
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
|
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
|
||||||
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
||||||
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
||||||
|
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
|
||||||
|
|
||||||
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
|
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
|
||||||
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||||
@@ -48,7 +50,7 @@ Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-mo
|
|||||||
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` claim lives in the
|
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` claim lives in the
|
||||||
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
|
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
|
||||||
|
|
||||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`.
|
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
|
||||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
|
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
|
||||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
|
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
|
||||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
||||||
@@ -96,8 +98,10 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
|||||||
- `task_attachments`
|
- `task_attachments`
|
||||||
|
|
||||||
Managed by EF Core migrations in `Migrations/` — **`ls Migrations/` is the authoritative history**;
|
Managed by EF Core migrations in `Migrations/` — **`ls Migrations/` is the authoritative history**;
|
||||||
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), and
|
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`),
|
||||||
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
|
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK
|
||||||
|
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with
|
||||||
|
`blocked_by_task_id`).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
|||||||
builder.HasKey(s => s.Id);
|
builder.HasKey(s => s.Id);
|
||||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
|
||||||
|
|
||||||
|
builder.Property(s => s.NextTaskNumber)
|
||||||
|
.HasColumnName("next_task_number").IsRequired().HasDefaultValue(1);
|
||||||
|
|
||||||
builder.Property(s => s.DefaultClaudeInstructions)
|
builder.Property(s => s.DefaultClaudeInstructions)
|
||||||
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
|
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
|
||||||
builder.Property(s => s.DefaultModel)
|
builder.Property(s => s.DefaultModel)
|
||||||
@@ -55,10 +58,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
|||||||
builder.Property(s => s.UsageGateSevenDayPct)
|
builder.Property(s => s.UsageGateSevenDayPct)
|
||||||
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
|
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
|
||||||
|
|
||||||
builder.Property(s => s.UsageThrottleSoftPct)
|
builder.Property(s => s.UsageThrottleFiveHourSoftPct)
|
||||||
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
|
.HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
|
||||||
builder.Property(s => s.UsageThrottleHardPct)
|
builder.Property(s => s.UsageThrottleFiveHourHardPct)
|
||||||
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
|
.HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
|
||||||
|
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
|
||||||
|
builder.Property(s => s.UsageThrottleSevenDayHardPct)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
|
||||||
|
|
||||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
|||||||
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");
|
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
|
||||||
|
builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap")
|
||||||
|
.IsRequired().HasDefaultValue(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
|||||||
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||||
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
||||||
|
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
|
||||||
|
|
||||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
|
|
||||||
builder.HasKey(t => t.Id);
|
builder.HasKey(t => t.Id);
|
||||||
builder.Property(t => t.Id).HasColumnName("id");
|
builder.Property(t => t.Id).HasColumnName("id");
|
||||||
|
builder.Property(t => t.Number).HasColumnName("number").IsRequired();
|
||||||
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
|
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
|
||||||
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
|
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
|
||||||
builder.Property(t => t.Description).HasColumnName("description");
|
builder.Property(t => t.Description).HasColumnName("description");
|
||||||
@@ -74,10 +75,14 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
||||||
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
||||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
||||||
|
builder.Property(t => t.DependsOnTaskId).HasColumnName("depends_on_task_id");
|
||||||
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
|
||||||
builder.Property(t => t.Result).HasColumnName("result");
|
builder.Property(t => t.Result).HasColumnName("result");
|
||||||
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
||||||
builder.Property(t => t.RoadblockCount).HasColumnName("roadblock_count").HasDefaultValue(0);
|
builder.Property(t => t.RoadblockCount).HasColumnName("roadblock_count").HasDefaultValue(0);
|
||||||
|
builder.Property(t => t.FailureReason).HasColumnName("failure_reason");
|
||||||
|
builder.Property(t => t.FailureTurnsUsed).HasColumnName("failure_turns_used");
|
||||||
|
builder.Property(t => t.FailureMaxTurns).HasColumnName("failure_max_turns");
|
||||||
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
builder.Property(t => t.LogPath).HasColumnName("log_path");
|
||||||
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
|
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
builder.Property(t => t.StartedAt).HasColumnName("started_at");
|
builder.Property(t => t.StartedAt).HasColumnName("started_at");
|
||||||
@@ -93,6 +98,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||||
|
builder.Property(t => t.ScopeGlobs).HasColumnName("scope_globs");
|
||||||
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
|
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
|
||||||
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
|
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
|
||||||
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
|
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
|
||||||
@@ -115,6 +121,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
.HasForeignKey(t => t.BlockedByTaskId)
|
.HasForeignKey(t => t.BlockedByTaskId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes
|
||||||
|
// pickable rather than blocked forever on a task that no longer exists.
|
||||||
|
builder.HasOne<TaskEntity>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.DependsOnTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.WithMany(l => l.Tasks)
|
||||||
.HasForeignKey(t => t.ListId)
|
.HasForeignKey(t => t.ListId)
|
||||||
@@ -124,10 +137,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
.WithOne(w => w.Task)
|
.WithOne(w => w.Task)
|
||||||
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
|
||||||
|
|
||||||
|
builder.HasIndex(t => t.Number).IsUnique().HasDatabaseName("idx_tasks_number");
|
||||||
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
|
||||||
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
|
||||||
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
|
||||||
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
|
|||||||
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
builder.Property(r => r.StartedAt).HasColumnName("started_at");
|
||||||
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
|
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
|
||||||
builder.Property(r => r.Model).HasColumnName("model");
|
builder.Property(r => r.Model).HasColumnName("model");
|
||||||
|
builder.Property(r => r.ResultSubtype).HasColumnName("result_subtype");
|
||||||
|
builder.Property(r => r.TerminalReason).HasColumnName("terminal_reason");
|
||||||
|
builder.Property(r => r.Errors).HasColumnName("errors");
|
||||||
|
|
||||||
builder.HasOne(r => r.Task)
|
builder.HasOne(r => r.Task)
|
||||||
.WithMany(t => t.Runs)
|
.WithMany(t => t.Runs)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering.Filters;
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
public sealed class ReviewFilter : ITaskListFilter
|
public sealed class ReviewFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => "virtual:review";
|
public override string Id => "virtual:review";
|
||||||
public bool Matches(TaskEntity t) =>
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
|
||||||
t.Status == TaskStatus.WaitingForReview;
|
public override bool ShouldCount(TaskEntity t) => Matches(t);
|
||||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters;
|
|||||||
/// Filter for a smart list keyed off a boolean/nullable task flag
|
/// Filter for a smart list keyed off a boolean/nullable task flag
|
||||||
/// (My Day, Important, Planned). Counts only non-done matches.
|
/// (My Day, Important, Planned). Counts only non-done matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
|
public sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
|
||||||
public bool Matches(TaskEntity t) => flag(t);
|
|
||||||
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
|
public override string Id => id;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
|
||||||
|
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters;
|
|||||||
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
/// Virtual list filter matching tasks by a single status (Queued, Running).
|
||||||
/// Planning parents appear contextually when they host a matching child.
|
/// Planning parents appear contextually when they host a matching child.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
|
public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
|
||||||
{
|
{
|
||||||
public string Id => id;
|
public override string Id => id;
|
||||||
public bool Matches(TaskEntity t) => t.Status == status;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
|
||||||
public bool ShouldCount(TaskEntity t) => t.Status == status;
|
public override bool ShouldCount(TaskEntity t) => t.Status == status;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
|
||||||
PlanningRules.IsPlanningParent(t) &&
|
PlanningRules.IsPlanningParent(t) &&
|
||||||
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Filtering.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
|
||||||
|
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
|
||||||
|
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
|
||||||
|
/// on first use, as the in-memory <see cref="Matches"/> predicate.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class TaskListFilterBase : ITaskListFilter
|
||||||
|
{
|
||||||
|
private Func<TaskEntity, bool>? _compiled;
|
||||||
|
|
||||||
|
public abstract string Id { get; }
|
||||||
|
|
||||||
|
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
|
||||||
|
|
||||||
|
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
|
||||||
|
|
||||||
|
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
|
||||||
|
|
||||||
|
public abstract bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters;
|
|||||||
/// Filter for any user-defined list. Constructed on demand from the list id —
|
/// Filter for any user-defined list. Constructed on demand from the list id —
|
||||||
/// one instance per list.
|
/// one instance per list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UserListFilter : ITaskListFilter
|
public sealed class UserListFilter : TaskListFilterBase
|
||||||
{
|
{
|
||||||
private readonly string _listId;
|
private readonly string _listId;
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
|
|||||||
Id = $"user:{listId}";
|
Id = $"user:{listId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Id { get; }
|
public override string Id { get; }
|
||||||
public bool Matches(TaskEntity t) => t.ListId == _listId;
|
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
|
||||||
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Filtering;
|
namespace ClaudeDo.Data.Filtering;
|
||||||
@@ -15,6 +16,9 @@ public interface ITaskListFilter
|
|||||||
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
|
||||||
bool Matches(TaskEntity t);
|
bool Matches(TaskEntity t);
|
||||||
|
|
||||||
|
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
|
||||||
|
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
|
||||||
|
|
||||||
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
|
||||||
bool ShouldCount(TaskEntity t);
|
bool ShouldCount(TaskEntity t);
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ public sealed record MergeSegment
|
|||||||
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
||||||
public string Theirs { get; init; } = "";
|
public string Theirs { get; init; } = "";
|
||||||
|
|
||||||
public static MergeSegment Stable(string text) => new() { Text = text };
|
/// <summary>1-based line number in the file where this segment starts (the marker line for a conflict).</summary>
|
||||||
|
public int StartLine { get; init; } = 1;
|
||||||
|
|
||||||
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
public static MergeSegment Stable(string text, int startLine = 1) => new() { Text = text, StartLine = startLine };
|
||||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
|
||||||
|
public static MergeSegment Conflict(string ours, string? @base, string theirs, int startLine = 1) =>
|
||||||
|
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs, StartLine = startLine };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -49,22 +52,25 @@ public static class ConflictMarkerParser
|
|||||||
var segments = new List<MergeSegment>();
|
var segments = new List<MergeSegment>();
|
||||||
var lines = SplitKeepLineEndings(fileText);
|
var lines = SplitKeepLineEndings(fileText);
|
||||||
var stable = new StringBuilder();
|
var stable = new StringBuilder();
|
||||||
|
var stableStartLine = 1;
|
||||||
var i = 0;
|
var i = 0;
|
||||||
|
|
||||||
while (i < lines.Count)
|
while (i < lines.Count)
|
||||||
{
|
{
|
||||||
if (!IsMarker(lines[i], OursMarker))
|
if (!IsMarker(lines[i], OursMarker))
|
||||||
{
|
{
|
||||||
|
if (stable.Length == 0) stableStartLine = i + 1;
|
||||||
stable.Append(lines[i++]);
|
stable.Append(lines[i++]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stable.Length > 0)
|
if (stable.Length > 0)
|
||||||
{
|
{
|
||||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
|
||||||
stable.Clear();
|
stable.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var conflictStartLine = i + 1;
|
||||||
i++; // consume "<<<<<<<"
|
i++; // consume "<<<<<<<"
|
||||||
var ours = new StringBuilder();
|
var ours = new StringBuilder();
|
||||||
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
||||||
@@ -88,11 +94,11 @@ public static class ConflictMarkerParser
|
|||||||
|
|
||||||
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
||||||
|
|
||||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
|
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString(), conflictStartLine));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stable.Length > 0)
|
if (stable.Length > 0)
|
||||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ using System.Text;
|
|||||||
|
|
||||||
namespace ClaudeDo.Data.Git;
|
namespace ClaudeDo.Data.Git;
|
||||||
|
|
||||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
// TreeOid is only populated when Clean — the tree `merge-tree --write-tree` would produce,
|
||||||
|
// usable to materialize the merge result into a scratch worktree without touching the real
|
||||||
|
// working tree, index, or refs (see GitService.CommitTreeAsync / WorktreeAddDetachedAsync).
|
||||||
|
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles, string? TreeOid = null);
|
||||||
|
|
||||||
public sealed class GitService
|
public sealed class GitService
|
||||||
{
|
{
|
||||||
@@ -42,6 +45,16 @@ public sealed class GitService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
|
||||||
|
/// callers treat that as "can't evaluate", not "no common history".
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
|
||||||
|
return exitCode == 0 ? stdout.Trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await WorktreeAddGate.WaitAsync(ct);
|
await WorktreeAddGate.WaitAsync(ct);
|
||||||
@@ -70,9 +83,12 @@ public sealed class GitService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --untracked-files=all: without it, a brand-new untracked directory collapses into a single
|
||||||
|
// "?? dir/" entry instead of listing the files inside it — callers matching against specific
|
||||||
|
// paths (e.g. the untracked-collision guard) need the individual files.
|
||||||
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
|
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
||||||
return stdout;
|
return stdout;
|
||||||
@@ -123,16 +139,20 @@ public sealed class GitService
|
|||||||
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
|
public async Task<string> GetDiffAsync(
|
||||||
|
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
var args = new List<string> { "diff", "HEAD" };
|
||||||
["diff", "HEAD"], ct);
|
AppendPathFilter(args, paths);
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
|
||||||
// If nothing staged vs HEAD, try the index (untracked is never in diff)
|
// If nothing staged vs HEAD, try the index (untracked is never in diff)
|
||||||
if (string.IsNullOrWhiteSpace(stdout))
|
if (string.IsNullOrWhiteSpace(stdout))
|
||||||
{
|
{
|
||||||
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
|
var cachedArgs = new List<string> { "diff", "--cached" };
|
||||||
|
AppendPathFilter(cachedArgs, paths);
|
||||||
|
var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, ct);
|
||||||
if (e2 == 0) return s2;
|
if (e2 == 0) return s2;
|
||||||
}
|
}
|
||||||
return stdout;
|
return stdout;
|
||||||
@@ -143,14 +163,19 @@ public sealed class GitService
|
|||||||
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
|
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
|
||||||
/// task's total impact relative to where the branch started.
|
/// task's total impact relative to where the branch started.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
|
public async Task<string> GetBranchDiffAsync(
|
||||||
|
string worktreePath, string baseRef, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
|
var args = new List<string> { "diff", baseRef };
|
||||||
["diff", baseRef], ct);
|
AppendPathFilter(args, paths);
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(worktreePath, args, ct);
|
||||||
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
|
||||||
return stdout;
|
return stdout;
|
||||||
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
|
// Fallback: whatever the worktree has vs HEAD (uncommitted only). The same pathspec has to
|
||||||
return await GetDiffAsync(worktreePath, ct);
|
// come along — a filter makes an empty ranged diff the common case (the caller asked for
|
||||||
|
// paths this range never touched), and an unfiltered fallback would answer that with the
|
||||||
|
// whole worktree diff, the exact opposite of what was requested.
|
||||||
|
return await GetDiffAsync(worktreePath, paths, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -158,24 +183,38 @@ public sealed class GitService
|
|||||||
/// task's changes after its worktree has been merged away (the commits survive on
|
/// task's changes after its worktree has been merged away (the commits survive on
|
||||||
/// the target branch even though the worktree directory and branch ref are gone).
|
/// the target branch even though the worktree directory and branch ref are gone).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
|
public async Task<string> GetCommitRangeDiffAsync(
|
||||||
|
string repoDir, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
var args = new List<string> { "diff", $"{baseCommit}..{headCommit}" };
|
||||||
["diff", $"{baseCommit}..{headCommit}"], ct);
|
AppendPathFilter(args, paths);
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, args, ct);
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
public async Task<string> DiffStatAsync(
|
||||||
|
string worktreePath, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
var args = new List<string> { "diff", "--stat", $"{baseCommit}..{headCommit}" };
|
||||||
["diff", "--stat", $"{baseCommit}..{headCommit}"], ct);
|
AppendPathFilter(args, paths);
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
|
||||||
if (exitCode != 0)
|
if (exitCode != 0)
|
||||||
throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
|
throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
|
||||||
return stdout.Trim();
|
return stdout.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Appends a `-- <paths>` pathspec filter so git itself narrows the diff instead of the
|
||||||
|
// caller filtering the result after the fact (works identically for --stat and full diffs).
|
||||||
|
// No-op when paths is null/empty so existing callers see no behavior change.
|
||||||
|
private static void AppendPathFilter(List<string> args, IReadOnlyList<string>? paths)
|
||||||
|
{
|
||||||
|
if (paths is not { Count: > 0 }) return;
|
||||||
|
args.Add("--");
|
||||||
|
args.AddRange(paths);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
|
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
string[] args = string.IsNullOrEmpty(baseCommit)
|
string[] args = string.IsNullOrEmpty(baseCommit)
|
||||||
@@ -357,7 +396,8 @@ public sealed class GitService
|
|||||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
||||||
|
|
||||||
if (exitCode == 0)
|
if (exitCode == 0)
|
||||||
return new MergePreview(true, true, Array.Empty<string>());
|
// stdout is just the written tree's oid on a clean merge.
|
||||||
|
return new MergePreview(true, true, Array.Empty<string>(), stdout.Trim());
|
||||||
|
|
||||||
if (exitCode == 1)
|
if (exitCode == 1)
|
||||||
{
|
{
|
||||||
@@ -377,6 +417,62 @@ public sealed class GitService
|
|||||||
return new MergePreview(false, false, Array.Empty<string>());
|
return new MergePreview(false, false, Array.Empty<string>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves <paramref name="revision"/> (a branch, tag, or SHA) to a full commit SHA.</summary>
|
||||||
|
public async Task<string> RevParseAsync(string repoDir, string revision, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["rev-parse", revision], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git rev-parse '{revision}' failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new commit object wrapping <paramref name="treeOid"/> with <paramref name="parentSha"/>
|
||||||
|
/// as its single parent, writing only a loose object — no ref is created or moved.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> CommitTreeAsync(
|
||||||
|
string repoDir, string treeOid, string parentSha, string message, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["commit-tree", treeOid, "-p", parentSha, "-m", message], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git commit-tree failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materializes <paramref name="commitish"/> into a new, branchless worktree at
|
||||||
|
/// <paramref name="worktreePath"/> (detached HEAD) — used to build/verify a merge-tree
|
||||||
|
/// result without ever creating a branch or touching the real working tree.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WorktreeAddDetachedAsync(
|
||||||
|
string repoDir, string worktreePath, string commitish, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await WorktreeAddGate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const int maxAttempts = 3;
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["worktree", "add", "--detach", worktreePath, commitish], ct);
|
||||||
|
if (exitCode == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!transient || attempt >= maxAttempts)
|
||||||
|
throw new InvalidOperationException($"git worktree add --detach failed (exit {exitCode}): {stderr}");
|
||||||
|
|
||||||
|
await Task.Delay(150 * attempt, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WorktreeAddGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
||||||
public async Task<int> CountChangedFilesAsync(
|
public async Task<int> CountChangedFilesAsync(
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||||
@@ -389,6 +485,43 @@ public sealed class GitService
|
|||||||
.Count(s => s.Length > 0);
|
.Count(s => s.Length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see
|
||||||
|
/// what a target branch itself picked up since a task's fork point, as opposed to
|
||||||
|
/// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
|
||||||
|
public async Task<IReadOnlyList<string>> GetChangedFileNamesAsync(
|
||||||
|
string repoDir, string fromRef, string toRef, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||||
|
["diff", "--name-only", $"{fromRef}..{toRef}"], ct);
|
||||||
|
if (exitCode != 0) return Array.Empty<string>();
|
||||||
|
return stdout
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebases the branch checked out at <paramref name="worktreePath"/> onto <paramref name="ontoRef"/>.
|
||||||
|
/// On conflict or any other failure the rebase is aborted before returning, so the worktree is left
|
||||||
|
/// exactly as it was rather than stranded mid-rebase; <c>ConflictFiles</c> is best-effort and only
|
||||||
|
/// populated when the failure was an actual conflict.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(int ExitCode, string Stderr, IReadOnlyList<string> ConflictFiles)> RebaseAsync(
|
||||||
|
string worktreePath, string ontoRef, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["rebase", ontoRef], ct);
|
||||||
|
if (exitCode == 0)
|
||||||
|
return (0, stderr, Array.Empty<string>());
|
||||||
|
|
||||||
|
List<string> conflictFiles;
|
||||||
|
try { conflictFiles = await ListConflictedFilesAsync(worktreePath, ct); }
|
||||||
|
catch { conflictFiles = new(); }
|
||||||
|
|
||||||
|
await RunGitAsync(worktreePath, ["rebase", "--abort"], ct);
|
||||||
|
return (exitCode, stderr, conflictFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||||
{
|
{
|
||||||
|
|||||||
+883
@@ -0,0 +1,883 @@
|
|||||||
|
// <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("20260806141710_SplitUsageThrottlePerBucket")]
|
||||||
|
partial class SplitUsageThrottlePerBucket
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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,70 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SplitUsageThrottlePerBucket : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_seven_day_soft_pct");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "usage_throttle_five_hour_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 65);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "usage_throttle_five_hour_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 50);
|
||||||
|
|
||||||
|
// The old single soft/hard pair was compared against whichever bucket was more utilized,
|
||||||
|
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
|
||||||
|
// the split — the rename above already preserved it for the 7d side.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE app_settings
|
||||||
|
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
|
||||||
|
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "usage_throttle_five_hour_hard_pct",
|
||||||
|
table: "app_settings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "usage_throttle_five_hour_soft_pct",
|
||||||
|
table: "app_settings");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_seven_day_soft_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_soft_pct");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "usage_throttle_seven_day_hard_pct",
|
||||||
|
table: "app_settings",
|
||||||
|
newName: "usage_throttle_hard_pct");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
// <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("20260810080113_AddFindingsTracked")]
|
||||||
|
partial class AddFindingsTracked
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFindingsTracked : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "findings_tracked",
|
||||||
|
table: "lists",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "findings_tracked",
|
||||||
|
table: "lists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+899
@@ -0,0 +1,899 @@
|
|||||||
|
// <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("20260810113643_AddScopeOverlapFields")]
|
||||||
|
partial class AddScopeOverlapFields
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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<bool>("SerializeOnFileOverlap")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("serialize_on_file_overlap");
|
||||||
|
|
||||||
|
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>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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>("ScopeGlobs")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scope_globs");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddScopeOverlapFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "scope_globs",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "serialize_on_file_overlap",
|
||||||
|
table: "list_config",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "scope_globs",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "serialize_on_file_overlap",
|
||||||
|
table: "list_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
// <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("20260810115110_AddTaskDependency")]
|
||||||
|
partial class AddTaskDependency
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
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>("DependsOnTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("depends_on_task_id");
|
||||||
|
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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>("ScopeGlobs")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scope_globs");
|
||||||
|
|
||||||
|
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("DependsOnTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_depends_on");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnTaskId")
|
||||||
|
.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,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskDependency : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "depends_on_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_depends_on",
|
||||||
|
table: "tasks",
|
||||||
|
column: "depends_on_task_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_tasks_tasks_depends_on_task_id",
|
||||||
|
table: "tasks",
|
||||||
|
column: "depends_on_task_id",
|
||||||
|
principalTable: "tasks",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_tasks_tasks_depends_on_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_depends_on",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "depends_on_task_id",
|
||||||
|
table: "tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
// <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("20260810115437_AddFailureReason")]
|
||||||
|
partial class AddFailureReason
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
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<int?>("FailureMaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("FailureReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("failure_reason");
|
||||||
|
|
||||||
|
b.Property<int?>("FailureTurnsUsed")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_turns_used");
|
||||||
|
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
b.Property<string>("Errors")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("errors");
|
||||||
|
|
||||||
|
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<string>("ResultSubtype")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_subtype");
|
||||||
|
|
||||||
|
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<string>("TerminalReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("terminal_reason");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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,78 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFailureReason : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "failure_max_turns",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "failure_reason",
|
||||||
|
table: "tasks",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "failure_turns_used",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "errors",
|
||||||
|
table: "task_runs",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "result_subtype",
|
||||||
|
table: "task_runs",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "terminal_reason",
|
||||||
|
table: "task_runs",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "failure_max_turns",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "failure_reason",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "failure_turns_used",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "errors",
|
||||||
|
table: "task_runs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "result_subtype",
|
||||||
|
table: "task_runs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "terminal_reason",
|
||||||
|
table: "task_runs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
// <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("20260811081530_AddTaskNumbers")]
|
||||||
|
partial class AddTaskNumbers
|
||||||
|
{
|
||||||
|
/// <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(40)
|
||||||
|
.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<int>("MaxTurnsCeiling")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(80)
|
||||||
|
.HasColumnName("max_turns_ceiling");
|
||||||
|
|
||||||
|
b.Property<string>("ModelPresets")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
|
b.Property<int>("NextTaskNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1)
|
||||||
|
.HasColumnName("next_task_number");
|
||||||
|
|
||||||
|
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>("UsageThrottleFiveHourHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_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 = 40,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
MaxTurnsCeiling = 80,
|
||||||
|
NextTaskNumber = 1,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
UsageGateFiveHourPct = 80,
|
||||||
|
UsageGateSevenDayPct = 90,
|
||||||
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
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<bool>("SerializeOnFileOverlap")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("serialize_on_file_overlap");
|
||||||
|
|
||||||
|
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>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
|
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>("DependsOnTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("depends_on_task_id");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<int?>("FailureMaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("FailureReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("failure_reason");
|
||||||
|
|
||||||
|
b.Property<int?>("FailureTurnsUsed")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_turns_used");
|
||||||
|
|
||||||
|
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<string>("InteractiveSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("interactive_session_id");
|
||||||
|
|
||||||
|
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<int>("Number")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
|
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>("ScopeGlobs")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scope_globs");
|
||||||
|
|
||||||
|
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("DependsOnTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_depends_on");
|
||||||
|
|
||||||
|
b.HasIndex("ListId")
|
||||||
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx_tasks_number");
|
||||||
|
|
||||||
|
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<int?>("CacheReadTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_read_tokens");
|
||||||
|
|
||||||
|
b.Property<int?>("CacheWriteTokens")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("cache_write_tokens");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
b.Property<string>("Errors")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("errors");
|
||||||
|
|
||||||
|
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<string>("ResultSubtype")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_subtype");
|
||||||
|
|
||||||
|
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<string>("TerminalReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("terminal_reason");
|
||||||
|
|
||||||
|
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>("MergeCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("merge_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.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnTaskId")
|
||||||
|
.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,67 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskNumbers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "number",
|
||||||
|
table: "tasks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "next_task_number",
|
||||||
|
table: "app_settings",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
// Backfill in creation order (tiebreak by id) so pre-existing tasks get a stable,
|
||||||
|
// gapless 1..N numbering. Must run before the unique index below.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE tasks
|
||||||
|
SET number = (
|
||||||
|
SELECT COUNT(*) FROM tasks t2
|
||||||
|
WHERE t2.created_at < tasks.created_at
|
||||||
|
OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
UPDATE app_settings
|
||||||
|
SET next_task_number = (SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)
|
||||||
|
WHERE id = 1;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "idx_tasks_number",
|
||||||
|
table: "tasks",
|
||||||
|
column: "number",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "idx_tasks_number",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "number",
|
||||||
|
table: "tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "next_task_number",
|
||||||
|
table: "app_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("model_presets");
|
.HasColumnName("model_presets");
|
||||||
|
|
||||||
|
b.Property<int>("NextTaskNumber")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1)
|
||||||
|
.HasColumnName("next_task_number");
|
||||||
|
|
||||||
b.Property<string>("RepoImportFolders")
|
b.Property<string>("RepoImportFolders")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("repo_import_folders");
|
.HasColumnName("repo_import_folders");
|
||||||
@@ -106,17 +112,29 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue(90)
|
.HasDefaultValue(90)
|
||||||
.HasColumnName("usage_gate_seven_day_pct");
|
.HasColumnName("usage_gate_seven_day_pct");
|
||||||
|
|
||||||
b.Property<int>("UsageThrottleHardPct")
|
b.Property<int>("UsageThrottleFiveHourHardPct")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasDefaultValue(65)
|
.HasDefaultValue(65)
|
||||||
.HasColumnName("usage_throttle_hard_pct");
|
.HasColumnName("usage_throttle_five_hour_hard_pct");
|
||||||
|
|
||||||
b.Property<int>("UsageThrottleSoftPct")
|
b.Property<int>("UsageThrottleFiveHourSoftPct")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasDefaultValue(50)
|
.HasDefaultValue(50)
|
||||||
.HasColumnName("usage_throttle_soft_pct");
|
.HasColumnName("usage_throttle_five_hour_soft_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDayHardPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(65)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_hard_pct");
|
||||||
|
|
||||||
|
b.Property<int>("UsageThrottleSevenDaySoftPct")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(50)
|
||||||
|
.HasColumnName("usage_throttle_seven_day_soft_pct");
|
||||||
|
|
||||||
b.Property<int>("WorktreeAutoCleanupDays")
|
b.Property<int>("WorktreeAutoCleanupDays")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -152,11 +170,14 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
DefaultPermissionMode = "auto",
|
DefaultPermissionMode = "auto",
|
||||||
MaxParallelExecutions = 1,
|
MaxParallelExecutions = 1,
|
||||||
MaxTurnsCeiling = 80,
|
MaxTurnsCeiling = 80,
|
||||||
|
NextTaskNumber = 1,
|
||||||
StandupWeekday = 3,
|
StandupWeekday = 3,
|
||||||
UsageGateFiveHourPct = 80,
|
UsageGateFiveHourPct = 80,
|
||||||
UsageGateSevenDayPct = 90,
|
UsageGateSevenDayPct = 90,
|
||||||
UsageThrottleHardPct = 65,
|
UsageThrottleFiveHourHardPct = 65,
|
||||||
UsageThrottleSoftPct = 50,
|
UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
UsageThrottleSevenDayHardPct = 65,
|
||||||
|
UsageThrottleSevenDaySoftPct = 50,
|
||||||
WorktreeAutoCleanupDays = 7,
|
WorktreeAutoCleanupDays = 7,
|
||||||
WorktreeAutoCleanupEnabled = false,
|
WorktreeAutoCleanupEnabled = false,
|
||||||
WorktreeStrategy = "sibling"
|
WorktreeStrategy = "sibling"
|
||||||
@@ -211,6 +232,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("model");
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<bool>("SerializeOnFileOverlap")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("serialize_on_file_overlap");
|
||||||
|
|
||||||
b.Property<string>("SessionSkills")
|
b.Property<string>("SessionSkills")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("session_skills");
|
.HasColumnName("session_skills");
|
||||||
@@ -245,6 +272,12 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasDefaultValue("chore")
|
.HasDefaultValue("chore")
|
||||||
.HasColumnName("default_commit_type");
|
.HasColumnName("default_commit_type");
|
||||||
|
|
||||||
|
b.Property<bool>("FindingsTracked")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("findings_tracked");
|
||||||
|
|
||||||
b.Property<bool>("IsManual")
|
b.Property<bool>("IsManual")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
@@ -447,10 +480,26 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("created_by");
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
|
b.Property<string>("DependsOnTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("depends_on_task_id");
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<int?>("FailureMaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("FailureReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("failure_reason");
|
||||||
|
|
||||||
|
b.Property<int?>("FailureTurnsUsed")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("failure_turns_used");
|
||||||
|
|
||||||
b.Property<DateTime?>("FinishedAt")
|
b.Property<DateTime?>("FinishedAt")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("finished_at");
|
.HasColumnName("finished_at");
|
||||||
@@ -506,6 +555,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("notes");
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("number");
|
||||||
|
|
||||||
b.Property<string>("ParentTaskId")
|
b.Property<string>("ParentTaskId")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("parent_task_id");
|
.HasColumnName("parent_task_id");
|
||||||
@@ -547,6 +600,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("scheduled_for");
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<string>("ScopeGlobs")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scope_globs");
|
||||||
|
|
||||||
b.Property<string>("SessionSkills")
|
b.Property<string>("SessionSkills")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("session_skills");
|
.HasColumnName("session_skills");
|
||||||
@@ -580,9 +637,16 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("BlockedByTaskId")
|
b.HasIndex("BlockedByTaskId")
|
||||||
.HasDatabaseName("idx_tasks_blocked_by");
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
|
b.HasIndex("DependsOnTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_depends_on");
|
||||||
|
|
||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("Number")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("idx_tasks_number");
|
||||||
|
|
||||||
b.HasIndex("ParentTaskId")
|
b.HasIndex("ParentTaskId")
|
||||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
@@ -613,6 +677,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("error_markdown");
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
b.Property<string>("Errors")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("errors");
|
||||||
|
|
||||||
b.Property<int?>("ExitCode")
|
b.Property<int?>("ExitCode")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("exit_code");
|
.HasColumnName("exit_code");
|
||||||
@@ -644,6 +712,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("result_markdown");
|
.HasColumnName("result_markdown");
|
||||||
|
|
||||||
|
b.Property<string>("ResultSubtype")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_subtype");
|
||||||
|
|
||||||
b.Property<int>("RunNumber")
|
b.Property<int>("RunNumber")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("run_number");
|
.HasColumnName("run_number");
|
||||||
@@ -665,6 +737,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("task_id");
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<string>("TerminalReason")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("terminal_reason");
|
||||||
|
|
||||||
b.Property<int?>("TokensIn")
|
b.Property<int?>("TokensIn")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("tokens_in");
|
.HasColumnName("tokens_in");
|
||||||
@@ -805,6 +881,11 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasForeignKey("BlockedByTaskId")
|
.HasForeignKey("BlockedByTaskId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DependsOnTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
.WithMany("Tasks")
|
.WithMany("Tasks")
|
||||||
.HasForeignKey("ListId")
|
.HasForeignKey("ListId")
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ public sealed class AppSettingsEntity
|
|||||||
|
|
||||||
public int Id { get; set; } = SingletonId;
|
public int Id { get; set; } = SingletonId;
|
||||||
|
|
||||||
|
// Persistent counter backing TaskNumberAllocator: the next task number to hand out. Lives on
|
||||||
|
// the singleton row so allocation is a single atomic UPDATE...RETURNING in the task's own
|
||||||
|
// insert transaction, never a MAX(number)+1 recompute.
|
||||||
|
public int NextTaskNumber { get; set; } = 1;
|
||||||
|
|
||||||
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
public string DefaultClaudeInstructions { get; set; } = string.Empty;
|
||||||
public string DefaultModel { get; set; } = "sonnet";
|
public string DefaultModel { get; set; } = "sonnet";
|
||||||
public int DefaultMaxTurns { get; set; } = 40;
|
public int DefaultMaxTurns { get; set; } = 40;
|
||||||
@@ -43,8 +48,11 @@ public sealed class AppSettingsEntity
|
|||||||
public int UsageGateFiveHourPct { get; set; } = 80;
|
public int UsageGateFiveHourPct { get; set; } = 80;
|
||||||
public int UsageGateSevenDayPct { get; set; } = 90;
|
public int UsageGateSevenDayPct { get; set; } = 90;
|
||||||
|
|
||||||
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
|
// Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
|
||||||
// parallelism ahead of the hard gate above. 0 = that stage off.
|
// Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
|
||||||
public int UsageThrottleSoftPct { get; set; } = 50;
|
// parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
|
||||||
public int UsageThrottleHardPct { get; set; } = 65;
|
public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
|
||||||
|
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
|
||||||
|
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
|
||||||
|
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public sealed class ListConfigEntity
|
|||||||
public string? SessionSkills { get; set; }
|
public string? SessionSkills { get; set; }
|
||||||
public string? VerifyCommand { get; set; }
|
public string? VerifyCommand { get; set; }
|
||||||
|
|
||||||
|
// Off by default: tasks in this list run in parallel exactly as before. When on, the queue
|
||||||
|
// picker holds back a queued task whose declared ScopeGlobs overlaps a running or
|
||||||
|
// awaiting-merge sibling in the same list instead of claiming it.
|
||||||
|
public bool SerializeOnFileOverlap { get; set; }
|
||||||
|
|
||||||
// Navigation property
|
// Navigation property
|
||||||
public ListEntity List { get; set; } = null!;
|
public ListEntity List { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public sealed class ListEntity
|
|||||||
// A manual list holds reminders rather than Claude work: new tasks created here start out
|
// A manual list holds reminders rather than Claude work: new tasks created here start out
|
||||||
// manual (see TaskEntity.IsManual).
|
// manual (see TaskEntity.IsManual).
|
||||||
public bool IsManual { get; set; }
|
public bool IsManual { get; set; }
|
||||||
|
// When true the project's .claudedo/ findings store is committed with the repo; when false it
|
||||||
|
// is kept out of git via .git/info/exclude (see FindingsStore).
|
||||||
|
public bool FindingsTracked { get; set; }
|
||||||
|
|
||||||
// Navigation properties
|
// Navigation properties
|
||||||
public ListConfigEntity? Config { get; set; }
|
public ListConfigEntity? Config { get; set; }
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ public static class ModelRegistry
|
|||||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
|
||||||
|
|
||||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus", "fable" };
|
||||||
|
|
||||||
public const string DefaultAlias = "sonnet";
|
public const string DefaultAlias = "sonnet";
|
||||||
public const string PlanningAlias = "opus";
|
public const string PlanningAlias = "opus";
|
||||||
|
public const string HandlerTriageAlias = "opus";
|
||||||
|
public const string HandlerWaitAlias = "sonnet";
|
||||||
|
public const string HandlerMergeAlias = "opus";
|
||||||
|
|
||||||
public const string ListDefaultSentinel = "(default)";
|
public const string ListDefaultSentinel = "(default)";
|
||||||
public const string TaskInheritSentinel = "(inherit)";
|
public const string TaskInheritSentinel = "(inherit)";
|
||||||
|
|||||||
@@ -22,16 +22,33 @@ public enum PlanningPhase
|
|||||||
public sealed class TaskEntity
|
public sealed class TaskEntity
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
|
// Global, monotonically increasing, never-reused display/lookup handle shown as `#123`.
|
||||||
|
// Allocated by TaskNumberAllocator from AppSettingsEntity.NextTaskNumber -- never MAX(number)+1,
|
||||||
|
// which would reissue a deleted task's number. The GUID Id stays the real identity everywhere else.
|
||||||
|
public int Number { get; set; }
|
||||||
public required string ListId { get; init; }
|
public required string ListId { get; init; }
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
public TaskStatus Status { get; set; } = TaskStatus.Idle;
|
||||||
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
||||||
public string? BlockedByTaskId { get; set; }
|
public string? BlockedByTaskId { get; set; }
|
||||||
|
|
||||||
|
// A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own
|
||||||
|
// internal link): the picker also skips a Queued task while this is set and the referenced
|
||||||
|
// task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade
|
||||||
|
// or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService.
|
||||||
|
// SetDependsOnAsync and QueuePicker).
|
||||||
|
public string? DependsOnTaskId { get; set; }
|
||||||
public DateTime? ScheduledFor { get; set; }
|
public DateTime? ScheduledFor { get; set; }
|
||||||
public string? Result { get; set; }
|
public string? Result { get; set; }
|
||||||
public string? ReviewFeedback { get; set; }
|
public string? ReviewFeedback { get; set; }
|
||||||
public int RoadblockCount { get; set; }
|
public int RoadblockCount { get; set; }
|
||||||
|
// Denormalized from the failing run, same pattern as RoadblockCount, so get_task/
|
||||||
|
// batch_get_tasks can report why a Failed task stopped without a second query.
|
||||||
|
// Null on a non-Failed task, or on a Failed task predating this field ("unknown").
|
||||||
|
public string? FailureReason { get; set; }
|
||||||
|
public int? FailureTurnsUsed { get; set; }
|
||||||
|
public int? FailureMaxTurns { get; set; }
|
||||||
public string? LogPath { get; set; }
|
public string? LogPath { get; set; }
|
||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
public DateTime? StartedAt { get; set; }
|
public DateTime? StartedAt { get; set; }
|
||||||
@@ -51,6 +68,12 @@ public sealed class TaskEntity
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public string? SessionSkills { get; set; }
|
public string? SessionSkills { get; set; }
|
||||||
|
|
||||||
|
// Newline-separated declared file paths/globs this task expects to touch. User-supplied only
|
||||||
|
// (never inferred). Only consulted by the queue picker when the list's
|
||||||
|
// ListConfigEntity.SerializeOnFileOverlap is on, and only for this task's own overlap check --
|
||||||
|
// an unset value means "no basis to hold this task back", not "touches nothing".
|
||||||
|
public string? ScopeGlobs { get; set; }
|
||||||
|
|
||||||
// Review range for a worktree-less task hosting an interactive "list handler" run
|
// Review range for a worktree-less task hosting an interactive "list handler" run
|
||||||
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
|
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
|
||||||
// straight to the list's working dir, so there is no per-task worktree to diff. These
|
// straight to the list's working dir, so there is no per-task worktree to diff. These
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public sealed class TaskRunEntity
|
|||||||
public DateTime? StartedAt { get; set; }
|
public DateTime? StartedAt { get; set; }
|
||||||
public DateTime? FinishedAt { get; set; }
|
public DateTime? FinishedAt { get; set; }
|
||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
|
// Raw diagnostics from the CLI's stream-json result event (StreamAnalyzer), kept verbatim
|
||||||
|
// for get_run/get_task_log — TaskEntity.FailureReason is the classified, MCP-facing summary.
|
||||||
|
public string? ResultSubtype { get; set; }
|
||||||
|
public string? TerminalReason { get; set; }
|
||||||
|
public string? Errors { get; set; }
|
||||||
|
|
||||||
// Navigation property
|
// Navigation property
|
||||||
public TaskEntity Task { get; set; } = null!;
|
public TaskEntity Task { get; set; } = null!;
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ public static class Paths
|
|||||||
return Path.GetFullPath(expanded);
|
return Path.GetFullPath(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips trailing directory separators off a path bound for a Windows command line. Windows
|
||||||
|
/// argv rules read <c>\"</c> as an escaped quote, so a quoted token ending in '\' never closes
|
||||||
|
/// (<c>"C:\repo\"</c> parses as <c>C:\repo"</c>) and either the path itself or every argument
|
||||||
|
/// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched;
|
||||||
|
/// so is null/blank.
|
||||||
|
/// </summary>
|
||||||
|
public static string? TrimTrailingSeparator(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return path;
|
||||||
|
|
||||||
|
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
|
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
|
||||||
public static string AppDataRoot() =>
|
public static string AppDataRoot() =>
|
||||||
Expand("~/.todo-app");
|
Expand("~/.todo-app");
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace ClaudeDo.Data;
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff }
|
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
||||||
@@ -38,7 +39,9 @@ public static class PromptFiles
|
|||||||
PromptKind.WeeklyReport => "weekly-report.md",
|
PromptKind.WeeklyReport => "weekly-report.md",
|
||||||
PromptKind.ImprovementChild => "improvement-child.md",
|
PromptKind.ImprovementChild => "improvement-child.md",
|
||||||
PromptKind.Refine => "refine.md",
|
PromptKind.Refine => "refine.md",
|
||||||
PromptKind.MergeHelper => "merge-helper-system.md",
|
PromptKind.MergeHelperTriage => "merge-helper-triage.md",
|
||||||
|
PromptKind.MergeHelperWait => "merge-helper-wait.md",
|
||||||
|
PromptKind.MergeHelperMerge => "merge-helper-merge.md",
|
||||||
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
|
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
|
||||||
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
|
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||||
@@ -192,14 +195,16 @@ public static class PromptFiles
|
|||||||
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
|
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
|
||||||
=> RenderTemplate(ReadOrDefault(kind), values);
|
=> RenderTemplate(ReadOrDefault(kind), values);
|
||||||
|
|
||||||
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
|
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.
|
||||||
|
/// Single pass over the template, so a token appearing inside a substituted VALUE is never
|
||||||
|
/// re-substituted. That matters because the values are user-authored task titles and
|
||||||
|
/// descriptions: a sharpened description mentioning a literal {repo} must survive verbatim,
|
||||||
|
/// and it must not depend on the caller happening to order its dictionary correctly.</summary>
|
||||||
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
|
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
|
||||||
{
|
=> TokenPattern.Replace(template, m =>
|
||||||
var sb = new StringBuilder(template);
|
values.TryGetValue(m.Groups[1].Value, out var val) ? val : m.Value);
|
||||||
foreach (var (key, val) in values)
|
|
||||||
sb.Replace("{" + key + "}", val);
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string DefaultFor(PromptKind kind) => kind switch
|
public static string DefaultFor(PromptKind kind) => kind switch
|
||||||
{
|
{
|
||||||
@@ -211,7 +216,9 @@ public static class PromptFiles
|
|||||||
PromptKind.WeeklyReport => WeeklyReportDefault,
|
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||||
PromptKind.ImprovementChild => ImprovementChildDefault,
|
PromptKind.ImprovementChild => ImprovementChildDefault,
|
||||||
PromptKind.Refine => RefineDefault,
|
PromptKind.Refine => RefineDefault,
|
||||||
PromptKind.MergeHelper => MergeHelperDefault,
|
PromptKind.MergeHelperTriage => MergeHelperTriageDefault,
|
||||||
|
PromptKind.MergeHelperWait => MergeHelperWaitDefault,
|
||||||
|
PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
|
||||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||||
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
|
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
|
||||||
_ => ""
|
_ => ""
|
||||||
@@ -238,6 +245,10 @@ public static class PromptFiles
|
|||||||
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
|
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
|
||||||
complex work (cheapest to most capable: haiku < sonnet < opus).
|
complex work (cheapest to most capable: haiku < sonnet < opus).
|
||||||
|
|
||||||
|
This tool only exists for a standalone top-level task. A child task or a task in a
|
||||||
|
planning chain does not have it — improvements are one layer deep. If you don't have
|
||||||
|
it, name the follow-up in your final report instead of trying to file it.
|
||||||
|
|
||||||
## Working in the repo
|
## Working in the repo
|
||||||
- Read a file before editing it. Match the conventions already in this codebase —
|
- Read a file before editing it. Match the conventions already in this codebase —
|
||||||
they override generic defaults.
|
they override generic defaults.
|
||||||
@@ -324,8 +335,13 @@ public static class PromptFiles
|
|||||||
until the user has approved the design.
|
until the user has approved the design.
|
||||||
|
|
||||||
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
|
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
|
||||||
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
|
To shape it you have the ClaudeDo planning tools: CreateChildTask, ListChildTasks,
|
||||||
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
|
UpdateChildTask, DeleteChildTask, UpdatePlanningTask and Finalize. You also have
|
||||||
|
Skill (needed for the brainstorming skill above), Read, Grep, Glob, WebFetch and
|
||||||
|
WebSearch. You do NOT have Write, Edit or Bash.
|
||||||
|
|
||||||
|
Use Read/Grep/Glob to ground the plan in the actual repo — a subtask that names the
|
||||||
|
real files and symbols involved runs far better than one written from guesswork.
|
||||||
|
|
||||||
Once the design is approved, create the child tasks with CreateChildTask, then
|
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||||
call Finalize. Keep each subtask concrete and self-contained with a clear
|
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||||
@@ -400,16 +416,18 @@ public static class PromptFiles
|
|||||||
not already present in the current subtasks above.
|
not already present in the current subtasks above.
|
||||||
|
|
||||||
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
||||||
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
mcp__claudedo__add_subtask, and — only when a repository is available — read-only
|
||||||
task, stop.
|
Read/Grep/Glob. When you have updated the task, stop.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string MergeHelperDefault = """
|
private const string MergeHelperTriageDefault = """
|
||||||
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, a task whose intent is too unclear to act on safely, a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, or a task whose intent is too unclear to act on safely. Everything else, decide and keep moving.
|
||||||
|
|
||||||
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
|
Your job: take the tasks listed in the brief and get the set ready to run — reading them all first, removing duplicates, then sharpening what stays. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
|
||||||
|
|
||||||
Work the five phases in order. Do not start a phase before the previous one is finished.
|
A second session takes over after you to run, review and merge these tasks. Your deliverable is a clean, sharpened set of task descriptions — you never run or merge anything yourself, and you touch no git state.
|
||||||
|
|
||||||
|
Work the three phases in order. Do not start a phase before the previous one is finished.
|
||||||
|
|
||||||
## Phase 0 — Read everything
|
## Phase 0 — Read everything
|
||||||
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
|
||||||
@@ -426,6 +444,13 @@ public static class PromptFiles
|
|||||||
Cancel nothing without an explicit answer.
|
Cancel nothing without an explicit answer.
|
||||||
|
|
||||||
## Phase 2 — Enhance for execution
|
## Phase 2 — Enhance for execution
|
||||||
|
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below.
|
||||||
|
|
||||||
|
If no verifyCommand is set:
|
||||||
|
1. Determine the repo's canonical, WIDE build-and-test command — read its CLAUDE.md/README. It must be a full build plus the complete test suite, deliberately NOT something narrowed to this run's tasks: a narrow verify only checks the files a task touched and lets test collisions slip through that a src-only build would never see.
|
||||||
|
2. Run that command yourself once via Bash to prove it actually works in this repo.
|
||||||
|
3. Only then ask the user to confirm it, and write it with set_list_config(verifyCommand: ...). This value outlives the run and applies to every future task on this list, so confirm — never set it silently.
|
||||||
|
|
||||||
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
|
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
|
||||||
- concrete acceptance criteria — what must be true when it is done,
|
- concrete acceptance criteria — what must be true when it is done,
|
||||||
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
|
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
|
||||||
@@ -438,51 +463,80 @@ public static class PromptFiles
|
|||||||
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for — the "do not invent requirements" rule still applies.
|
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for — the "do not invent requirements" rule still applies.
|
||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 3–5 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
|
Once every surviving task is enhanced, print your triage summary — one line per task from the brief:
|
||||||
|
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no).
|
||||||
|
|
||||||
## Phase 3 — Run
|
Then call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
|
||||||
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
|
""";
|
||||||
|
|
||||||
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
|
private const string MergeHelperWaitDefault = """
|
||||||
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. This is the Wait phase — the shortest and cheapest of the three list-handler roles. Your only job is to queue the tasks you were handed and wait for them to finish running; you do not read diffs, merge anything, or restart anything. The Merge session that runs after you owns all of those decisions.
|
||||||
|
|
||||||
For each surviving task, resolve the effective max-turns it will run with — task.MaxTurns, else the list's get_list_config MaxTurns, else the model's preset/global default — and report it. If a task looks substantial (several files, or one you just split off above) but its effective turns look low, say so and ask before queuing it; set_task_config/get_task_config let you raise it per task.
|
You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. A prior session already sharpened these task descriptions — do not re-read or re-edit them.
|
||||||
|
|
||||||
Then, for each surviving task:
|
## Phase 3 — Queue and wait
|
||||||
- Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
|
1. Queue every task handed to you: update_task_status(id, "Queued") for each one.
|
||||||
- Queued → leave it; it is already waiting for a slot.
|
2. Wait with wait_for_task_change instead of sleeping and polling get_task yourself. Pass every handed-off id, set treatWaitingForChildrenAsBusy=true — without that flag a task with children is reported done the moment it reaches WaitingForChildren while its children are still working — and use timeoutSeconds 900. The call blocks server-side up to 900s and pings MCP progress every 30s, so one long wait survives your client's own idle-silence abort and costs one turn where several short ones would cost several.
|
||||||
- Running or WaitingForChildren → leave it; only poll.
|
3. Repeat step 2 with whatever ids are still Queued, Running or WaitingForChildren, until none remain.
|
||||||
- WaitingForReview → leave it; it goes straight to Phase 4.
|
|
||||||
|
|
||||||
Call wait_for_task_change with the ids of every task still Queued or Running (timeoutSeconds up to 170) instead of sleeping and polling get_task yourself. It returns as soon as any of them leaves Queued/Running — WaitingForReview on success, Failed on error — or reports timedOut if none did. Report progress as tasks land, then call it again with whatever ids are still Queued/Running until none remain.
|
Touch nothing else: do not read a diff, merge, reset, restart, or cancel any task. A task that lands in Failed is simply noted and passed on — deciding what to do about it belongs to the Merge session, not you.
|
||||||
|
|
||||||
|
MaxParallelExecutions is deliberately not reported here — the queue enforces it on its own; that reporting was the old combined phase's job and no longer belongs to this role.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
Once every task has left Queued/Running/WaitingForChildren, call handoff_list_handler(taskId, <every task id from this round>, nextPhase: "merge") — or nextPhase: "merge_final" if your own phase was "wait_final". Then stop your turn immediately.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string MergeHelperMergeDefault = """
|
||||||
|
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
|
||||||
|
|
||||||
|
Your job: review and merge the tasks a prior Wait session ran, then decide what — if anything — deserves one rerun. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. The handed-off id list is only the SET of tasks — not their current state: call batch_get_tasks yourself to read where each one actually landed.
|
||||||
|
|
||||||
## Phase 4 — Review and merge
|
## Phase 4 — Review and merge
|
||||||
Before merging anything, call preview_merge_set with every surviving task's id (the same targetBranch you are about to merge into). It tells you, per task, whether a clean merge-tree preview is even possible (status/conflictFiles/changedFileCount/behind) and which files more than one of the tasks changed (overlaps). Read the overlaps: a file two tasks both touch is where a same-branch collision could happen. This is a HINT, not proof — it only catches same-file overlap, not a cross-file break (e.g. one task deletes a symbol another task's file still references), and a clean preview never guarantees the result compiles or passes tests. Use it to decide merge order and to know which pairs to look at extra carefully in step 1 below; it does not replace reading the diffs.
|
|
||||||
|
|
||||||
One task at a time. Default to the order the brief lists them. If the overlap check above (or the file lists you gathered in Phase 2) shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) — deviate from brief order only with that stated reason.
|
Merge everything mergeable BEFORE starting any rerun. A rerun's fresh worktree forks from the CURRENT main, so running it after the merges lets it fork from the already-updated base and merge conflict-free; a rerun started before the merges forks from the old base and walks straight into a conflict with the merges still to come.
|
||||||
|
|
||||||
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description. If preview_merge_set flagged this task in an overlap, also skim the diff of the other task(s) sharing that file.
|
Before merging anything, call preview_merge_set with every candidate task's id (the same targetBranch you are about to merge into). It tells you, per task, whether a clean merge-tree preview is even possible (status/conflictFiles/changedFileCount/behind) and which files more than one task changed (overlaps). Read the overlaps: a file two tasks both touch is where a same-branch collision could happen. This is a HINT, not proof — it only catches same-file overlap, not a cross-file break (e.g. one task deletes a symbol another task's file still references), and a clean preview never guarantees the result compiles or passes tests.
|
||||||
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
|
||||||
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) — deviate from brief order only with that stated reason.
|
||||||
- Clean merge → the task is Done; move on.
|
|
||||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it.
|
1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file.
|
||||||
|
2. Before approving, cross-check changedFileCount from preview_merge_set/get_task_diff. 0 changed files means an empty branch, not a clean success — do not approve it; treat it like a risky diff instead.
|
||||||
|
3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
||||||
|
4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
||||||
|
- Clean merge, verify gate passes (or no verify command is set) → the task is Done; move on.
|
||||||
|
- Clean merge, verify gate fails → the merge stands, but the Done transition is deliberately withheld. Report this task as merged-but-not-Done and move on — do NOT try to fix the verify failure yourself; that is separate follow-up work.
|
||||||
|
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
|
||||||
|
|
||||||
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
|
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
|
||||||
|
|
||||||
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||||
|
|
||||||
Resolving a conflict:
|
Resolving a conflict — do this yourself, never via a subagent, since every subagent would share this session's same main checkout:
|
||||||
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
||||||
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
|
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
|
||||||
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
||||||
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||||
|
|
||||||
Rules for the whole session:
|
|
||||||
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||||
- Ask the user for anything ambiguous, risky, or destructive.
|
|
||||||
|
## Failed tasks — only after every merge above is done
|
||||||
|
|
||||||
|
For each task that landed in Failed, read terminal_reason before deciding anything — Failed is a catch-all covering an account/usage limit, a max-turns cutoff, and a genuine crash, and each deserves a different response. Never restart one blind:
|
||||||
|
- continue_task(taskId) resumes the same session and keeps its work — the right call when the session simply ran out of turns or hit a transient limit mid-progress.
|
||||||
|
- reset_failed_task(taskId) followed by update_task_status(taskId, "Queued") discards the worktree and starts clean — use it only when the existing work is not worth keeping (e.g. a genuine crash early on).
|
||||||
|
- Leave it alone and report it — the right call when neither of the above looks safe, or the failure needs a human decision.
|
||||||
|
|
||||||
|
Allow at most ONE rerun per task in this session.
|
||||||
|
|
||||||
|
## Handoff / end
|
||||||
|
|
||||||
|
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately — do not merge anything yet that you just restarted.
|
||||||
|
- If your own phase was "merge_final": never start a rerun, no matter how tempting — just merge what you can, report the rest, and print the summary below.
|
||||||
|
- Otherwise (no reruns started, phase was "merge"): print the summary below and stop; there is nothing left to hand off.
|
||||||
|
|
||||||
## Phase 5 — Summary
|
## Phase 5 — Summary
|
||||||
Print one line per task from the original brief:
|
Print one line per task from the brief:
|
||||||
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any).
|
title — final status — merge commit (if any) — conflicts resolved (if any) — verify-failed (yes/no).
|
||||||
|
|
||||||
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||||
""";
|
""";
|
||||||
@@ -507,12 +561,13 @@ public static class PromptFiles
|
|||||||
Repo: {repo}
|
Repo: {repo}
|
||||||
|
|
||||||
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
|
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
|
||||||
for the tasks below — their descriptions are already sharpened, so don't redo phases 0–2.
|
for the tasks below — their descriptions are already sharpened.
|
||||||
|
|
||||||
{tasks}
|
{tasks}
|
||||||
|
|
||||||
Start with phase 3 (run), continuing through review/merge and the summary as your
|
Start with phase 3 (run), continuing through review/merge and the summary as your
|
||||||
instructions describe.
|
instructions describe.
|
||||||
|
{finalNote}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string WeeklyReportDefault = """
|
private const string WeeklyReportDefault = """
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ public sealed class AppSettingsRepository
|
|||||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||||
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
|
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
|
||||||
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
|
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
|
||||||
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
|
row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
|
||||||
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
|
row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
|
||||||
|
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
|
||||||
|
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
|
||||||
|
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,25 @@ public sealed class ListRepository
|
|||||||
|
|
||||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
NormalizeWorkingDir(entity);
|
||||||
_context.Lists.Add(entity);
|
_context.Lists.Add(entity);
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
NormalizeWorkingDir(entity);
|
||||||
_context.Lists.Update(entity);
|
_context.Lists.Update(entity);
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every
|
||||||
|
// consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token —
|
||||||
|
// where a trailing '\' escapes its own closing quote. Normalizing here covers all writers;
|
||||||
|
// see Paths.TrimTrailingSeparator.
|
||||||
|
private static void NormalizeWorkingDir(ListEntity entity) =>
|
||||||
|
entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir);
|
||||||
|
|
||||||
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var taskIds = await _context.Tasks
|
var taskIds = await _context.Tasks
|
||||||
@@ -79,6 +88,7 @@ public sealed class ListRepository
|
|||||||
existing.MaxTurns = config.MaxTurns;
|
existing.MaxTurns = config.MaxTurns;
|
||||||
existing.SessionSkills = config.SessionSkills;
|
existing.SessionSkills = config.SessionSkills;
|
||||||
existing.VerifyCommand = config.VerifyCommand;
|
existing.VerifyCommand = config.VerifyCommand;
|
||||||
|
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
|
||||||
}
|
}
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ public sealed class TaskRepository
|
|||||||
.MaxAsync(ct);
|
.MaxAsync(ct);
|
||||||
entity.SortOrder = (maxSort ?? -1) + 1;
|
entity.SortOrder = (maxSort ?? -1) + 1;
|
||||||
|
|
||||||
_context.Tasks.Add(entity);
|
await TaskNumberAllocator.AddWithNumberAsync(_context, entity, ct);
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
||||||
@@ -50,6 +49,11 @@ public sealed class TaskRepository
|
|||||||
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
|
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
@@ -309,8 +313,7 @@ public sealed class TaskRepository
|
|||||||
Model = ModelRegistry.NormalizeAlias(model),
|
Model = ModelRegistry.NormalizeAlias(model),
|
||||||
MaxTurns = maxTurns,
|
MaxTurns = maxTurns,
|
||||||
};
|
};
|
||||||
_context.Tasks.Add(child);
|
await TaskNumberAllocator.AddWithNumberAsync(_context, child, ct);
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ public sealed class WorktreeRepository
|
|||||||
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
|
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
|
||||||
|
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
|
||||||
|
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _context.Worktrees
|
||||||
|
.Where(w => w.TaskId == taskId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(w => w.BaseCommit, baseCommit)
|
||||||
|
.SetProperty(w => w.HeadCommit, headCommit), ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
// Allocates TaskEntity.Number and inserts the task. Never MAX(number)+1 -- that would reissue a
|
||||||
|
// deleted task's number. Allocation is a single atomic UPDATE...RETURNING against the
|
||||||
|
// app_settings singleton row, which SQLite's single-writer model already serializes.
|
||||||
|
//
|
||||||
|
// The allocation and the insert are deliberately two separate statements, each committed on its
|
||||||
|
// own, rather than one shared transaction: a shared transaction would roll the counter bump back
|
||||||
|
// together with a failed insert, so a retry would hand out the exact same (still colliding)
|
||||||
|
// number every time and the bounded retry below would be pointless. Committing the bump
|
||||||
|
// unconditionally means a retry after a collision always advances to a fresh number, at the cost
|
||||||
|
// of burning the collided number as an unused gap -- which is fine, gaps are expected.
|
||||||
|
public static class TaskNumberAllocator
|
||||||
|
{
|
||||||
|
private const int MaxAttempts = 5;
|
||||||
|
|
||||||
|
public static async Task AddWithNumberAsync(
|
||||||
|
ClaudeDoDbContext context, TaskEntity entity, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
var settings = await context.AppSettings.FromSqlRaw(
|
||||||
|
"""
|
||||||
|
UPDATE app_settings SET next_task_number = next_task_number + 1
|
||||||
|
WHERE id = {0}
|
||||||
|
RETURNING *
|
||||||
|
""", AppSettingsEntity.SingletonId).AsNoTracking().ToListAsync(ct);
|
||||||
|
|
||||||
|
if (settings.Count == 0)
|
||||||
|
{
|
||||||
|
// The singleton row is missing (e.g. a corrupted/hand-edited DB) -- recreate it,
|
||||||
|
// as its own separate insert/commit, and retry the UPDATE above rather than
|
||||||
|
// indexing into an empty result. Doesn't count against MaxAttempts: it's not a
|
||||||
|
// number collision.
|
||||||
|
await EnsureSingletonRowAsync(context, ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Number = settings[0].NextTaskNumber - 1;
|
||||||
|
context.Tasks.Add(entity);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex)
|
||||||
|
{
|
||||||
|
context.Entry(entity).State = EntityState.Detached;
|
||||||
|
if (attempt < MaxAttempts && IsNumberCollision(ex))
|
||||||
|
continue;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSingletonRowAsync(ClaudeDoDbContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||||
|
context.AppSettings.Add(row);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// A concurrent process already inserted the singleton -- fine, the next UPDATE
|
||||||
|
// attempt above will find it.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
context.Entry(row).State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinguishes the expected unique-index collision on idx_tasks_number (worth retrying with
|
||||||
|
// a fresh number) from any other DbUpdateException -- an FK violation, a NOT NULL violation,
|
||||||
|
// etc. -- which must surface immediately instead of silently burning up to MaxAttempts numbers
|
||||||
|
// on a failure a retry can never fix.
|
||||||
|
private static bool IsNumberCollision(DbUpdateException ex) =>
|
||||||
|
ex.InnerException is SqliteException { SqliteErrorCode: 19 } sqliteEx
|
||||||
|
&& sqliteEx.Message.Contains("tasks.number", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -178,5 +178,6 @@ public sealed class ClaudeHelpLauncher
|
|||||||
|
|
||||||
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
|
||||||
|
|
||||||
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
|
private static string QuoteDirectory(string directory) =>
|
||||||
|
Quote(ClaudeDo.Data.Paths.TrimTrailingSeparator(directory)!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
"addSchedule": "+ Zeitplan hinzufügen",
|
"addSchedule": "+ Zeitplan hinzufügen",
|
||||||
"removeScheduleTip": "Zeitplan entfernen",
|
"removeScheduleTip": "Zeitplan entfernen",
|
||||||
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
|
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
|
||||||
|
"promptOverrideLabel": "Prompt-Zusatz",
|
||||||
|
"promptOverridePlaceholder": "Zusätzliche Anweisungen, die für diesen Zeitplan an den Prime-Prompt angehängt werden (optional)",
|
||||||
"dayMo": "Mo",
|
"dayMo": "Mo",
|
||||||
"dayTu": "Di",
|
"dayTu": "Di",
|
||||||
"dayWe": "Mi",
|
"dayWe": "Mi",
|
||||||
@@ -161,6 +163,7 @@
|
|||||||
"badgeDraft": "ENTWURF",
|
"badgeDraft": "ENTWURF",
|
||||||
"badgePlanned": "GEPLANT",
|
"badgePlanned": "GEPLANT",
|
||||||
"badgeManual": "MANUELL",
|
"badgeManual": "MANUELL",
|
||||||
|
"chainAfterPrefix": "nach",
|
||||||
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
||||||
"ctxMarkManual": "Als manuell markieren",
|
"ctxMarkManual": "Als manuell markieren",
|
||||||
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
||||||
@@ -197,6 +200,7 @@
|
|||||||
"contextWorktrees": "Worktrees…",
|
"contextWorktrees": "Worktrees…",
|
||||||
"contextOpenExplorer": "Im Explorer öffnen",
|
"contextOpenExplorer": "Im Explorer öffnen",
|
||||||
"contextOpenTerminal": "Im Terminal öffnen",
|
"contextOpenTerminal": "Im Terminal öffnen",
|
||||||
|
"contextOpenFindings": "Findings-Ordner öffnen",
|
||||||
"newList": "Neue Liste",
|
"newList": "Neue Liste",
|
||||||
"addReposTip": "Repos als Listen hinzufügen"
|
"addReposTip": "Repos als Listen hinzufügen"
|
||||||
},
|
},
|
||||||
@@ -263,7 +267,8 @@
|
|||||||
"chipDone": "FERTIG",
|
"chipDone": "FERTIG",
|
||||||
"chipFailed": "FEHLGESCHLAGEN",
|
"chipFailed": "FEHLGESCHLAGEN",
|
||||||
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
|
||||||
|
"reviewFeedbackPlaceholder": "Feedback für nächsten Lauf"
|
||||||
},
|
},
|
||||||
"missionControl": {
|
"missionControl": {
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
@@ -334,6 +339,8 @@
|
|||||||
"defaultCommitType": "Standard-Commit-Typ",
|
"defaultCommitType": "Standard-Commit-Typ",
|
||||||
"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.",
|
||||||
|
"findingsTracked": "Findings-Ordner .claudedo einchecken",
|
||||||
|
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||||
"sectionVerify": "VERIFIKATION",
|
"sectionVerify": "VERIFIKATION",
|
||||||
@@ -357,7 +364,14 @@
|
|||||||
"merge": "Mergen…",
|
"merge": "Mergen…",
|
||||||
"filesHeader": "Dateien",
|
"filesHeader": "Dateien",
|
||||||
"binary": "Binärdatei — kein Text-Diff",
|
"binary": "Binärdatei — kein Text-Diff",
|
||||||
"empty": "Kein Inhalt"
|
"empty": "Kein Inhalt",
|
||||||
|
"unifiedView": "Zusammen",
|
||||||
|
"splitView": "Nebeneinander",
|
||||||
|
"wrapLines": "Zeilenumbruch",
|
||||||
|
"paneBase": "BASIS",
|
||||||
|
"paneWorktree": "WORKTREE",
|
||||||
|
"maximize": "Maximieren",
|
||||||
|
"restore": "Wiederherstellen"
|
||||||
},
|
},
|
||||||
"worktreesOverview": {
|
"worktreesOverview": {
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
@@ -403,7 +417,9 @@
|
|||||||
"selectNone": "Keine auswählen",
|
"selectNone": "Keine auswählen",
|
||||||
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
||||||
"columnTask": "AUFGABE",
|
"columnTask": "AUFGABE",
|
||||||
"columnStatus": "STATUS"
|
"columnStatus": "STATUS",
|
||||||
|
"orphanHint": "ELTERNTEIL NICHT IN AUSWAHL",
|
||||||
|
"orphanHintTip": "Das Elternteil dieser Unteraufgabe wird hier nicht angeboten (bereits erledigt oder abgebrochen), daher steht sie einzeln."
|
||||||
},
|
},
|
||||||
"unfinishedPlanning": {
|
"unfinishedPlanning": {
|
||||||
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
||||||
@@ -433,6 +449,10 @@
|
|||||||
"gateBlockedFormat": "Queue pausiert — {0}",
|
"gateBlockedFormat": "Queue pausiert — {0}",
|
||||||
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
|
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
|
||||||
"resetIn": "Reset in {0}",
|
"resetIn": "Reset in {0}",
|
||||||
|
"dragHint": "Marker ziehen oder Wert unten eintragen.",
|
||||||
|
"legendSoft": "Soft · 2 Slots",
|
||||||
|
"legendHard": "Hard · 1 Slot",
|
||||||
|
"legendGate": "Gate · Pause",
|
||||||
"gaugeSession": "Session (5 Std.)",
|
"gaugeSession": "Session (5 Std.)",
|
||||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||||
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
||||||
@@ -642,32 +662,89 @@
|
|||||||
},
|
},
|
||||||
"vm": {
|
"vm": {
|
||||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
|
||||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||||
|
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
|
||||||
|
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
|
||||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||||
|
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
|
||||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
|
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
|
||||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||||
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
|
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
|
||||||
|
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}"
|
||||||
},
|
},
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
|
||||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen 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}" },
|
"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." },
|
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben." },
|
||||||
"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" },
|
||||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
|
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
|
||||||
},
|
},
|
||||||
|
"ops": {
|
||||||
|
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||||
|
"merge": {
|
||||||
|
"merging": "Wird zusammengeführt…",
|
||||||
|
"verifying": "Verify-Kommando der Liste läuft… ({0})"
|
||||||
|
},
|
||||||
|
"diff": {
|
||||||
|
"parsing": "Diff wird geparst…"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"submitting": "Wird zur Prüfung eingereicht…",
|
||||||
|
"rejecting": "Wird abgelehnt…",
|
||||||
|
"parking": "Wird geparkt…",
|
||||||
|
"previewing": "Vorschau wird geladen…"
|
||||||
|
},
|
||||||
|
"worktrees": {
|
||||||
|
"refreshing": "Wird aktualisiert…",
|
||||||
|
"cleaningUp": "Abgeschlossene Worktrees werden aufgeräumt…",
|
||||||
|
"resetting": "Worktree wird zurückgesetzt…",
|
||||||
|
"forceRemoving": "Worktree wird zwangsweise entfernt…",
|
||||||
|
"batchMerging": "Merge {0}/{1}…"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"installing": "Skill wird geklont…",
|
||||||
|
"updating": "Skill wird aktualisiert…",
|
||||||
|
"restoringDefaults": "Standard-Agenten werden wiederhergestellt…"
|
||||||
|
},
|
||||||
|
"onlineInbox": {
|
||||||
|
"signingIn": "Wird angemeldet…"
|
||||||
|
},
|
||||||
|
"repoImport": {
|
||||||
|
"scanning": "Repositories werden gescannt…"
|
||||||
|
},
|
||||||
|
"updateCheck": {
|
||||||
|
"checking": "Wird nach Updates gesucht…"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"generatingWeekly": "Bericht wird erstellt…",
|
||||||
|
"runningDailyPrep": "Tagesvorbereitung läuft…"
|
||||||
|
},
|
||||||
|
"planning": {
|
||||||
|
"buildingIntegrationBranch": "Integrations-Branch wird erstellt…",
|
||||||
|
"loadingAggregate": "Kombinierte Vorschau wird geladen…",
|
||||||
|
"finalizing": "Plan wird finalisiert…",
|
||||||
|
"queuingSubtasks": "Teilaufgaben werden eingereiht…"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"startupRecovery": "Wiederherstellung läuft…",
|
||||||
|
"creatingWorktree": "Worktree wird erstellt…",
|
||||||
|
"rebasingAfterMerge": "Andere Worktrees werden rebased…",
|
||||||
|
"maintainingWorktrees": "Worktree-Wartung läuft…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"pill": {
|
"pill": {
|
||||||
"empty": "Nutzung –",
|
"empty": "Nutzung –",
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
"addSchedule": "+ Add schedule",
|
"addSchedule": "+ Add schedule",
|
||||||
"removeScheduleTip": "Remove schedule",
|
"removeScheduleTip": "Remove schedule",
|
||||||
"dailyPrepMaxTasks": "Max tasks per day",
|
"dailyPrepMaxTasks": "Max tasks per day",
|
||||||
|
"promptOverrideLabel": "Prompt addition",
|
||||||
|
"promptOverridePlaceholder": "Extra instructions appended to the daily prep prompt for this schedule (optional)",
|
||||||
"dayMo": "Mo",
|
"dayMo": "Mo",
|
||||||
"dayTu": "Tu",
|
"dayTu": "Tu",
|
||||||
"dayWe": "We",
|
"dayWe": "We",
|
||||||
@@ -161,6 +163,7 @@
|
|||||||
"badgeDraft": "DRAFT",
|
"badgeDraft": "DRAFT",
|
||||||
"badgePlanned": "PLANNED",
|
"badgePlanned": "PLANNED",
|
||||||
"badgeManual": "MANUAL",
|
"badgeManual": "MANUAL",
|
||||||
|
"chainAfterPrefix": "after",
|
||||||
"manualTip": "Manual reminder — Claude never picks this up",
|
"manualTip": "Manual reminder — Claude never picks this up",
|
||||||
"ctxMarkManual": "Mark as manual",
|
"ctxMarkManual": "Mark as manual",
|
||||||
"ctxMarkClaudeTask": "Mark as Claude task",
|
"ctxMarkClaudeTask": "Mark as Claude task",
|
||||||
@@ -197,6 +200,7 @@
|
|||||||
"contextWorktrees": "Worktrees…",
|
"contextWorktrees": "Worktrees…",
|
||||||
"contextOpenExplorer": "Open in Explorer",
|
"contextOpenExplorer": "Open in Explorer",
|
||||||
"contextOpenTerminal": "Open in Terminal",
|
"contextOpenTerminal": "Open in Terminal",
|
||||||
|
"contextOpenFindings": "Open findings folder",
|
||||||
"newList": "New list",
|
"newList": "New list",
|
||||||
"addReposTip": "Add repos as lists"
|
"addReposTip": "Add repos as lists"
|
||||||
},
|
},
|
||||||
@@ -263,7 +267,8 @@
|
|||||||
"chipDone": "DONE",
|
"chipDone": "DONE",
|
||||||
"chipFailed": "FAILED",
|
"chipFailed": "FAILED",
|
||||||
"reviewContinueTip": "Send this feedback and re-run the task",
|
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||||
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
"reviewResetTip": "Discard all changes and reset the task to Idle",
|
||||||
|
"reviewFeedbackPlaceholder": "Feedback for next run"
|
||||||
},
|
},
|
||||||
"missionControl": {
|
"missionControl": {
|
||||||
"windowTitle": "Mission Control",
|
"windowTitle": "Mission Control",
|
||||||
@@ -334,6 +339,8 @@
|
|||||||
"defaultCommitType": "Default commit type",
|
"defaultCommitType": "Default commit type",
|
||||||
"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.",
|
||||||
|
"findingsTracked": "Commit the .claudedo findings folder",
|
||||||
|
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Reset agent settings",
|
"resetAgentSettings": "Reset agent settings",
|
||||||
"sectionVerify": "VERIFICATION",
|
"sectionVerify": "VERIFICATION",
|
||||||
@@ -357,7 +364,14 @@
|
|||||||
"merge": "Merge…",
|
"merge": "Merge…",
|
||||||
"filesHeader": "Files",
|
"filesHeader": "Files",
|
||||||
"binary": "Binary file — no text diff",
|
"binary": "Binary file — no text diff",
|
||||||
"empty": "No content"
|
"empty": "No content",
|
||||||
|
"unifiedView": "Unified",
|
||||||
|
"splitView": "Side by side",
|
||||||
|
"wrapLines": "Wrap lines",
|
||||||
|
"paneBase": "BASE",
|
||||||
|
"paneWorktree": "WORKTREE",
|
||||||
|
"maximize": "Maximize",
|
||||||
|
"restore": "Restore"
|
||||||
},
|
},
|
||||||
"worktreesOverview": {
|
"worktreesOverview": {
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
@@ -403,7 +417,9 @@
|
|||||||
"selectNone": "Select none",
|
"selectNone": "Select none",
|
||||||
"empty": "No tasks to hand over — everything is done or cancelled.",
|
"empty": "No tasks to hand over — everything is done or cancelled.",
|
||||||
"columnTask": "TASK",
|
"columnTask": "TASK",
|
||||||
"columnStatus": "STATUS"
|
"columnStatus": "STATUS",
|
||||||
|
"orphanHint": "PARENT NOT IN SELECTION",
|
||||||
|
"orphanHintTip": "This subtask's parent isn't offered here (already done or cancelled), so it's listed on its own."
|
||||||
},
|
},
|
||||||
"unfinishedPlanning": {
|
"unfinishedPlanning": {
|
||||||
"title": "UNFINISHED PLANNING SESSION",
|
"title": "UNFINISHED PLANNING SESSION",
|
||||||
@@ -433,6 +449,10 @@
|
|||||||
"gateBlockedFormat": "Queue paused — {0}",
|
"gateBlockedFormat": "Queue paused — {0}",
|
||||||
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
|
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
|
||||||
"resetIn": "Reset in {0}",
|
"resetIn": "Reset in {0}",
|
||||||
|
"dragHint": "Drag a marker, or type the value below.",
|
||||||
|
"legendSoft": "Soft · 2 slots",
|
||||||
|
"legendHard": "Hard · 1 slot",
|
||||||
|
"legendGate": "Gate · pause",
|
||||||
"gaugeSession": "Session (5h)",
|
"gaugeSession": "Session (5h)",
|
||||||
"gaugeWeeklyAll": "Week (all models)",
|
"gaugeWeeklyAll": "Week (all models)",
|
||||||
"gaugeWeeklyScopedFormat": "Week ({0})",
|
"gaugeWeeklyScopedFormat": "Week ({0})",
|
||||||
@@ -642,32 +662,89 @@
|
|||||||
},
|
},
|
||||||
"vm": {
|
"vm": {
|
||||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Restarting worker…" },
|
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
|
||||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||||
|
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
|
||||||
|
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
|
||||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||||
|
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
|
||||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
|
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
|
||||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||||
"usageMonitor": {
|
"usageMonitor": {
|
||||||
"loadFailed": "Couldn't load usage data: {0}",
|
"loadFailed": "Couldn't load usage data: {0}",
|
||||||
"refreshFailed": "Couldn't refresh usage: {0}"
|
"refreshFailed": "Couldn't refresh usage: {0}",
|
||||||
|
"thresholdSaveFailed": "Couldn't save the threshold: {0}"
|
||||||
},
|
},
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
|
||||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove 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}" },
|
"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." },
|
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory." },
|
||||||
"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" },
|
||||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
|
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
|
||||||
},
|
},
|
||||||
|
"ops": {
|
||||||
|
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||||
|
"merge": {
|
||||||
|
"merging": "Merging…",
|
||||||
|
"verifying": "Running the list's verify command… ({0})"
|
||||||
|
},
|
||||||
|
"diff": {
|
||||||
|
"parsing": "Parsing diff…"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"submitting": "Submitting for review…",
|
||||||
|
"rejecting": "Rejecting…",
|
||||||
|
"parking": "Parking…",
|
||||||
|
"previewing": "Loading preview…"
|
||||||
|
},
|
||||||
|
"worktrees": {
|
||||||
|
"refreshing": "Refreshing…",
|
||||||
|
"cleaningUp": "Cleaning up finished worktrees…",
|
||||||
|
"resetting": "Resetting worktree…",
|
||||||
|
"forceRemoving": "Force-removing worktree…",
|
||||||
|
"batchMerging": "Merging {0}/{1}…"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"installing": "Cloning skill…",
|
||||||
|
"updating": "Updating skill…",
|
||||||
|
"restoringDefaults": "Restoring default agents…"
|
||||||
|
},
|
||||||
|
"onlineInbox": {
|
||||||
|
"signingIn": "Signing in…"
|
||||||
|
},
|
||||||
|
"repoImport": {
|
||||||
|
"scanning": "Scanning repositories…"
|
||||||
|
},
|
||||||
|
"updateCheck": {
|
||||||
|
"checking": "Checking for updates…"
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"generatingWeekly": "Generating report…",
|
||||||
|
"runningDailyPrep": "Running daily prep…"
|
||||||
|
},
|
||||||
|
"planning": {
|
||||||
|
"buildingIntegrationBranch": "Building integration branch…",
|
||||||
|
"loadingAggregate": "Loading combined preview…",
|
||||||
|
"finalizing": "Finalizing plan…",
|
||||||
|
"queuingSubtasks": "Queuing subtasks…"
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"startupRecovery": "Recovering…",
|
||||||
|
"creatingWorktree": "Creating worktree…",
|
||||||
|
"rebasingAfterMerge": "Rebasing other worktrees…",
|
||||||
|
"maintainingWorktrees": "Maintaining worktrees…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"pill": {
|
"pill": {
|
||||||
"empty": "Usage –",
|
"empty": "Usage –",
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ public static class VersionComparer
|
|||||||
| !Version.TryParse(CoreVersion(current), out var cv);
|
| !Version.TryParse(CoreVersion(current), out var cv);
|
||||||
|
|
||||||
if (unparseable) return new VersionCompareResult(false, true);
|
if (unparseable) return new VersionCompareResult(false, true);
|
||||||
return new VersionCompareResult(lv > cv, false);
|
if (lv != cv) return new VersionCompareResult(lv > cv, false);
|
||||||
|
|
||||||
|
// Same numeric core: a MinVer dev build sitting on main between two tags
|
||||||
|
// reports a prerelease of its own guessed *next* version (e.g. after tag
|
||||||
|
// v1.4.0, main computes "1.4.1-alpha.0.12" — already ahead of the tag by
|
||||||
|
// core, handled above). But when a real release is tagged at exactly that
|
||||||
|
// guessed version, the tag itself has no prerelease suffix while a still-
|
||||||
|
// running dev build does — that dev build must still count as older, or a
|
||||||
|
// genuine new release would be silently missed.
|
||||||
|
var isNewer = HasPrerelease(current) && !HasPrerelease(latest);
|
||||||
|
return new VersionCompareResult(isNewer, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reduce a tag/version to its numeric core: drop a leading "v", MinVer build
|
// Reduce a tag/version to its numeric core: drop a leading "v", MinVer build
|
||||||
@@ -18,4 +28,7 @@ public static class VersionComparer
|
|||||||
// which System.Version can parse. So "v1.0.2-alpha+abc" -> "1.0.2".
|
// which System.Version can parse. So "v1.0.2-alpha+abc" -> "1.0.2".
|
||||||
private static string CoreVersion(string value)
|
private static string CoreVersion(string value)
|
||||||
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Split('-')[0];
|
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Split('-')[0];
|
||||||
|
|
||||||
|
private static bool HasPrerelease(string value)
|
||||||
|
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Contains('-');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui;
|
namespace ClaudeDo.Ui;
|
||||||
@@ -10,24 +11,40 @@ public sealed class AppSettings
|
|||||||
public string Language { get; set; } = "";
|
public string Language { get; set; } = "";
|
||||||
public string AccentPreset { get; set; } = "";
|
public string AccentPreset { get; set; } = "";
|
||||||
|
|
||||||
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
|
/// Diff viewer layout: "unified" or "split". A view preference, so it lives here in
|
||||||
|
/// ui.config.json rather than in the worker-owned AppSettingsEntity.
|
||||||
|
public string DiffViewMode { get; set; } = "unified";
|
||||||
|
public bool DiffWrapLines { get; set; }
|
||||||
|
|
||||||
public static AppSettings Load()
|
private static readonly string DefaultConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
|
||||||
|
|
||||||
|
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
|
||||||
|
/// the diff-viewer toggles call Save() on every flip.
|
||||||
|
[JsonIgnore]
|
||||||
|
public string ConfigPath { get; set; } = DefaultConfigPath;
|
||||||
|
|
||||||
|
public static AppSettings Load(string? configPath = null)
|
||||||
{
|
{
|
||||||
|
var path = configPath ?? DefaultConfigPath;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(ConfigPath))
|
if (File.Exists(path))
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(ConfigPath);
|
var json = File.ReadAllText(path);
|
||||||
return JsonSerializer.Deserialize<AppSettings>(json,
|
var loaded = JsonSerializer.Deserialize<AppSettings>(json,
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (loaded is not null)
|
||||||
|
{
|
||||||
|
loaded.ConfigPath = path;
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Fall through to defaults
|
// Fall through to defaults
|
||||||
}
|
}
|
||||||
return new();
|
return new AppSettings { ConfigPath = path };
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save()
|
public void Save()
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ ViewModels/
|
|||||||
Conflicts/ — ConflictResolverViewModel + ConflictModels
|
Conflicts/ — ConflictResolverViewModel + ConflictModels
|
||||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||||
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
|
||||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
|
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
|
||||||
AgentConfigEditor
|
InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
|
||||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
||||||
```
|
```
|
||||||
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
|||||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||||
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
|
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
|
||||||
|
|
||||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||||
@@ -68,11 +68,20 @@ warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
|
|||||||
## Diff & Conflicts
|
## Diff & Conflicts
|
||||||
|
|
||||||
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
|
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
|
||||||
read-only viewer, Files and Planning modes) + `DiffLinesView`.
|
read-only viewer, Files and Planning modes) render through `DiffAlignment` (pure — pairs diff
|
||||||
|
lines into side-by-side rows and computes word-diff spans) and `DiffTextView` (AvaloniaEdit +
|
||||||
|
TextMate highlighting, unified/split layout, optional line wrap, synced scrolling). The
|
||||||
|
split/wrap toggles persist to `ui.config.json` via `AppSettings`.
|
||||||
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
|
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
|
||||||
single-task and planning unit-merge conflicts. Full detail →
|
single-task and planning unit-merge conflicts. Full detail →
|
||||||
[review-merge](../../docs/explore-notes/review-merge.md).
|
[review-merge](../../docs/explore-notes/review-merge.md).
|
||||||
|
|
||||||
|
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
|
||||||
|
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
|
||||||
|
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
|
||||||
|
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
|
||||||
|
new editor boilerplate there rather than copying it a third time.
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||||
@@ -80,6 +89,7 @@ single-task and planning unit-merge conflicts. Full detail →
|
|||||||
- **IPrimeScheduleApi** — prime-schedule CRUD.
|
- **IPrimeScheduleApi** — prime-schedule CRUD.
|
||||||
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
|
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
|
||||||
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
|
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
|
||||||
|
- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Off by default — `Shared` only writes when the environment variable `CLAUDEDO_OP_TIMING=1` is set at process start (read once statically, no per-call overhead). Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose** — `Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs (still writing — the kill switch only gates `Shared`'s default, not the constructor), because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4).
|
||||||
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
|
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
|
||||||
|
|
||||||
## Converters
|
## Converters
|
||||||
@@ -93,15 +103,23 @@ uppercase.
|
|||||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
|
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
|
||||||
sets the result on save/cancel, the caller awaits the TCS.
|
sets the result on save/cancel, the caller awaits the TCS.
|
||||||
|
|
||||||
|
`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
|
||||||
|
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
|
||||||
|
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
|
||||||
|
`OffScreenMargin` so a maximised extended-client-area window isn't clipped at the edges.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
|
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
|
||||||
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
||||||
|
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
|
||||||
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
|
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
|
||||||
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
|
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
|
||||||
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
|
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
|
||||||
- Context menus exist on both list and task rows; right-click selects before opening the menu.
|
- Context menus exist on both list and task rows; right-click selects before opening the menu.
|
||||||
- "Run Now" CanExecute re-evaluates when worker connection state changes.
|
- "Run Now" CanExecute re-evaluates when worker connection state changes.
|
||||||
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
|
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
|
||||||
|
- **Never set a `UserControl`'s own `DataContext` from a `StyledProperty`.** The property binding at the call site (`Status="{Binding RejectOp}"`) resolves *against that control's DataContext* — overwriting it re-targets the binding at the new value and the compiled getter throws `InvalidCastException`. Scope the DataContext to an inner panel instead (`DataContext="{Binding #Root.Status}"`, see `OperationIndicator.axaml`). Corollary: **the inner panel's DataContext is then null whenever the property is null**, so `IsVisible="{Binding ShowIndicator}"` can't resolve and falls back to the property default `true` — a permanent label-less spinner *plus* the stalled hint. `OperationIndicator` therefore collapses itself in code-behind when `Status` is null; wire every call site with `Status=`, never `DataContext=`.
|
||||||
|
- **Every `IWorkerClient` call in a `[RelayCommand]` runs through an `OperationStatus` (`Services/OperationStatus.cs`), shown via the `OperationIndicator` control** — never a handcrafted spinner. Without it the spinner `StackPanel` from `MergeModalView.axaml` gets rebuilt at every call site, and `IsBusy` flags lock the button but show nothing. **`Begin`/`Report`/`Dispose` must run on the UI thread** — only the tick is marshalled, so a `Task.Run`-wrapped call leaves the bindings updating off-thread (the startup update check did this). A `[RelayCommand]` satisfies this on its own. Separately, **`End` retires the generation** so tick work that was posted while the dispatcher was blocked can't drain afterwards and set `ShowIndicator` back to true on a finished operation — that was the real frozen-spinner bug, and the enabling condition (~2.4s blocked UI thread during window construction) is still there.
|
||||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
|
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
|
||||||
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
|
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
// Drives ListBoxItem.Focusable for TasksIslandView's flat Rows list: a HeaderRow container must
|
||||||
|
// not be a tab stop, a TaskRowViewModel container must behave like any other row.
|
||||||
|
public class NotHeaderRowConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static NotHeaderRowConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is not HeaderRow;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -108,6 +108,9 @@
|
|||||||
<!-- Icon.Settings (gear) -->
|
<!-- Icon.Settings (gear) -->
|
||||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Wrap — a full-width line above a line that wraps back with an arrowhead -->
|
||||||
|
<StreamGeometry x:Key="Icon.Wrap">M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
||||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
||||||
|
|
||||||
@@ -323,8 +326,9 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Icon button: 24×24 square with hover surface -->
|
<!-- Icon button: 24×24 square with hover surface. The ToggleButton variant is the same
|
||||||
<Style Selector="Button.icon-btn">
|
chrome for a persisted on/off control (its "on" state is the :checked rule below). -->
|
||||||
|
<Style Selector="Button.icon-btn, ToggleButton.icon-btn">
|
||||||
<Setter Property="Width" Value="24" />
|
<Setter Property="Width" Value="24" />
|
||||||
<Setter Property="Height" Value="24" />
|
<Setter Property="Height" Value="24" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
@@ -335,10 +339,53 @@
|
|||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter, ToggleButton.icon-btn:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.icon-btn:checked /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- PathIcon fills with its own Foreground and does not pick up TextElement.Foreground,
|
||||||
|
so the icon's three states are set on the icon itself. -->
|
||||||
|
<Style Selector="ToggleButton.icon-btn PathIcon">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.icon-btn:pointerover PathIcon">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.icon-btn:checked PathIcon">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
|
||||||
|
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
|
||||||
|
<Style Selector="Border.segmented">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="2" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.segmented Button.segment">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="10,4" />
|
||||||
|
<Setter Property="CornerRadius" Value="4" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.segmented Button.segment:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<!-- Declared after :pointerover so hovering the active segment keeps the active look. -->
|
||||||
|
<Style Selector="Border.segmented Button.segment.active /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Stroke-rendered icon (for line-art geometries that PathIcon would fill away) -->
|
<!-- Stroke-rendered icon (for line-art geometries that PathIcon would fill away) -->
|
||||||
<Style Selector="Button.icon-btn Path.plan-icon">
|
<Style Selector="Button.icon-btn Path.plan-icon">
|
||||||
@@ -437,12 +484,21 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
|
<!-- "Grabbed" row: collapses out of layout entirely while the custom drag runs — the
|
||||||
|
floating ghost snapshot (TaskDragController) is what the user sees moving. -->
|
||||||
<Style Selector="Border.task-row.dragging">
|
<Style Selector="Border.task-row.dragging">
|
||||||
<Setter Property="Opacity" Value="0.55" />
|
<Setter Property="IsVisible" Value="False" />
|
||||||
<Setter Property="RenderTransform" Value="scale(1.03)" />
|
<Setter Property="Transitions" Value="{x:Null}" />
|
||||||
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
|
</Style>
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
|
||||||
|
<!-- Row currently hovered while dragging (shows the dashed drop-hint gap): suppress the
|
||||||
|
ordinary hover highlight and transitions so they don't fight the hint. Placed after
|
||||||
|
Border.task-row:pointerover so its extra class also wins on declaration order. -->
|
||||||
|
<Style Selector="Border.task-row.drop-target">
|
||||||
|
<Setter Property="Transitions" Value="{x:Null}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.task-row.drop-target:pointerover">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||||
@@ -989,6 +1045,25 @@
|
|||||||
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
|
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CHAIN DEPENDENCY RAIL (step badge on the 24px indent track) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<Style Selector="Border.chain-step-badge">
|
||||||
|
<Setter Property="Width" Value="16"/>
|
||||||
|
<Setter Property="Height" Value="16"/>
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource PillCornerRadius}"/>
|
||||||
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chain-step-badge > TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}"/>
|
||||||
|
<Setter Property="FontSize" Value="{StaticResource FontSizeEyebrow}"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
|
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -1111,13 +1186,6 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Diff line-number gutter column -->
|
|
||||||
<Style Selector="TextBlock.diff-lineno">
|
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
|
||||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Terminal selectable log text (SelectableTextBlock doesn't inherit the TextBlock terminal style) -->
|
<!-- Terminal selectable log text (SelectableTextBlock doesn't inherit the TextBlock terminal style) -->
|
||||||
<Style Selector="Border.terminal SelectableTextBlock">
|
<Style Selector="Border.terminal SelectableTextBlock">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -111,6 +111,14 @@
|
|||||||
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
|
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
|
||||||
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
|
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
|
||||||
|
|
||||||
|
<!-- Diff viewer: filler/gap rows and intra-line (word) highlighting.
|
||||||
|
Whole-line add/delete tints reuse RunningTintBrush / ErrorTintBrush, which are low
|
||||||
|
enough alpha that syntax-highlighted text stays legible on top of them. -->
|
||||||
|
<SolidColorBrush x:Key="DiffFillerBrush" Color="#0AFFFFFF" /> <!-- "no line on this side" -->
|
||||||
|
<SolidColorBrush x:Key="DiffGapBrush" Color="#14FFFFFF" /> <!-- skipped region between hunks -->
|
||||||
|
<SolidColorBrush x:Key="DiffWordAddBrush" Color="#556FA86B" /> <!-- changed words, new side -->
|
||||||
|
<SolidColorBrush x:Key="DiffWordDelBrush" Color="#55C87060" /> <!-- changed words, old side -->
|
||||||
|
|
||||||
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
<GradientStop Offset="0" Color="#FF05070A" />
|
<GradientStop Offset="0" Color="#FF05070A" />
|
||||||
|
|||||||
@@ -25,14 +25,29 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||||
event Action<string, string>? TaskQuestionResolvedEvent;
|
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
|
|
||||||
/// <summary>A running list-handler session called handoff_list_handler at the end of Phase 2:
|
/// <summary>A running list-handler session called handoff_list_handler at the end of a phase:
|
||||||
/// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task.</summary>
|
/// (handlerTaskId, survivingTaskIds, nextPhase). The UI opens a second ConPTY tile for the same
|
||||||
event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
|
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||||
|
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||||
|
|
||||||
event Action? PrepStartedEvent;
|
event Action? PrepStartedEvent;
|
||||||
event Action<string>? PrepLineEvent;
|
event Action<string>? PrepLineEvent;
|
||||||
event Action<bool>? PrepFinishedEvent;
|
event Action<bool>? PrepFinishedEvent;
|
||||||
|
|
||||||
|
/// <summary>(opKey, phase, current, total) — generic progress channel for long-running worker
|
||||||
|
/// operations (merge phases, worktree cleanup, startup recovery, planning integration, ...).
|
||||||
|
/// opKey is the TaskId for a task-bound operation, otherwise a stable string. current/total
|
||||||
|
/// is meant to be shown as text, never a progress bar — most operations have no meaningful
|
||||||
|
/// total.</summary>
|
||||||
|
event Action<string, string, int, int>? OperationProgressEvent;
|
||||||
|
|
||||||
|
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
|
||||||
|
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
|
||||||
|
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
|
||||||
|
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
|
||||||
|
/// seconds riding in the generic "current" slot).</summary>
|
||||||
|
event Action<string, string, int>? MergeProgressEvent;
|
||||||
|
|
||||||
event Action<string, string>? PlanningMergeStartedEvent;
|
event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||||
@@ -75,7 +90,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||||
Task RemoveSessionSkillAsync(string sourceUrl);
|
Task RemoveSessionSkillAsync(string sourceUrl);
|
||||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
/// <summary>Returns the base-dirty heads-up when this transition just queued the task against
|
||||||
|
/// a list whose working dir has uncommitted changes (null otherwise) — a new worktree forks
|
||||||
|
/// from the last commit, not the working tree, so those changes won't be included.</summary>
|
||||||
|
Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||||
@@ -106,10 +124,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
||||||
Task<string> CreateMergeHelperTaskAsync(
|
Task<string> CreateMergeHelperTaskAsync(
|
||||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
|
||||||
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2
|
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once a phase
|
||||||
/// is done -- reuses the SAME handler task id (no new task created).</summary>
|
/// is done -- reuses the SAME handler task id (no new task created). nextPhase picks the next
|
||||||
|
/// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary>
|
||||||
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||||
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default);
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default);
|
||||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
/// Feedback primitive for a single long-running operation behind a [RelayCommand]. Multiple
|
||||||
|
/// independent instances per ViewModel are expected (one per command) — there is no shared or
|
||||||
|
/// static state, which is exactly what a static DispatcherTimer would introduce and the cause of
|
||||||
|
/// the order-dependent flakiness in Ui.Tests.
|
||||||
|
///
|
||||||
|
/// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other
|
||||||
|
/// three write their observable properties inline on the calling thread, so an off-thread caller
|
||||||
|
/// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its
|
||||||
|
/// awaits resume on the UI thread); wrapping the call in Task.Run breaks it.
|
||||||
|
public sealed partial class OperationStatus : ObservableObject
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300);
|
||||||
|
private static readonly TimeSpan StallThreshold = TimeSpan.FromSeconds(60);
|
||||||
|
private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private readonly TimeProvider _time;
|
||||||
|
private readonly Action<Action> _postToUiThread;
|
||||||
|
|
||||||
|
private ITimer? _tickTimer;
|
||||||
|
private DateTimeOffset _startedAt;
|
||||||
|
private DateTimeOffset _lastReportAt;
|
||||||
|
private int _generation;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isRunning;
|
||||||
|
[ObservableProperty] private bool _showIndicator;
|
||||||
|
[ObservableProperty] private string? _label;
|
||||||
|
[ObservableProperty] private string _elapsed = "00:00";
|
||||||
|
[ObservableProperty] private bool _isStalled;
|
||||||
|
|
||||||
|
public OperationStatus(TimeProvider? timeProvider = null) : this(timeProvider, null) { }
|
||||||
|
|
||||||
|
/// Test-only seam. Production always marshals the threadpool timer callback onto the
|
||||||
|
/// dispatcher; tests inject a synchronous poster so they can drive a fake clock without
|
||||||
|
/// bootstrapping a real Avalonia dispatcher.
|
||||||
|
internal OperationStatus(TimeProvider? timeProvider, Action<Action>? postToUiThread)
|
||||||
|
{
|
||||||
|
_time = timeProvider ?? TimeProvider.System;
|
||||||
|
_postToUiThread = postToUiThread ?? (action => Avalonia.Threading.Dispatcher.UIThread.Post(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the operation; Dispose (even via an exception unwinding a `using` block) ends it.
|
||||||
|
public IDisposable Begin(string label)
|
||||||
|
{
|
||||||
|
_tickTimer?.Dispose();
|
||||||
|
var generation = ++_generation;
|
||||||
|
|
||||||
|
_startedAt = _time.GetUtcNow();
|
||||||
|
_lastReportAt = _startedAt;
|
||||||
|
Label = label;
|
||||||
|
Elapsed = FormatElapsed(TimeSpan.Zero);
|
||||||
|
ShowIndicator = false;
|
||||||
|
IsStalled = false;
|
||||||
|
IsRunning = true;
|
||||||
|
|
||||||
|
_tickTimer = _time.CreateTimer(_ => OnTick(generation), null, GracePeriod, TickInterval);
|
||||||
|
|
||||||
|
return new EndOnDispose(this, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the label mid-flight and resets the stall clock — call this whenever the
|
||||||
|
/// operation's phase changes so a normally multi-minute step doesn't report itself as stuck.
|
||||||
|
public void Report(string label)
|
||||||
|
{
|
||||||
|
Label = label;
|
||||||
|
_lastReportAt = _time.GetUtcNow();
|
||||||
|
IsStalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(int generation) => _postToUiThread(() =>
|
||||||
|
{
|
||||||
|
if (generation != _generation) return;
|
||||||
|
|
||||||
|
var now = _time.GetUtcNow();
|
||||||
|
Elapsed = FormatElapsed(now - _startedAt);
|
||||||
|
if (!ShowIndicator && now - _startedAt >= GracePeriod) ShowIndicator = true;
|
||||||
|
IsStalled = now - _lastReportAt >= StallThreshold;
|
||||||
|
});
|
||||||
|
|
||||||
|
private void End(int generation)
|
||||||
|
{
|
||||||
|
if (generation != _generation) return;
|
||||||
|
// Retire the generation *before* touching the flags. The timer callback fires on a
|
||||||
|
// threadpool thread and only posts its UI work, so a tick that fired while the dispatcher
|
||||||
|
// was busy can drain *after* End — with the generation still current it would set
|
||||||
|
// ShowIndicator back to true on an already-finished operation, and since the timer is gone
|
||||||
|
// by then no later tick ever clears it. That is the frozen spinner.
|
||||||
|
_generation++;
|
||||||
|
_tickTimer?.Dispose();
|
||||||
|
_tickTimer = null;
|
||||||
|
IsRunning = false;
|
||||||
|
ShowIndicator = false;
|
||||||
|
IsStalled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatElapsed(TimeSpan elapsed) =>
|
||||||
|
(elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed).ToString(@"mm\:ss");
|
||||||
|
|
||||||
|
private sealed class EndOnDispose : IDisposable
|
||||||
|
{
|
||||||
|
private OperationStatus? _owner;
|
||||||
|
private readonly int _generation;
|
||||||
|
|
||||||
|
public EndOnDispose(OperationStatus owner, int generation)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_generation = generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
var owner = _owner;
|
||||||
|
_owner = null;
|
||||||
|
owner?.End(_generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends one NDJSON line per timed operation (hub invoke, bulk DB read/write) to a log file, so
|
||||||
|
/// a day of normal usage yields a file that can be sorted by duration to find real outliers.
|
||||||
|
/// Never throws — a failed write is swallowed, because a measurement must never disturb the app.
|
||||||
|
/// <see cref="Shared"/> is off by default; set <c>CLAUDEDO_OP_TIMING=1</c> to turn it back on.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OperationTiming
|
||||||
|
{
|
||||||
|
/// <summary>Fast successful operations are dropped: the first day of data was 43% 3ms reconcile
|
||||||
|
/// ticks, which crowd out the outliers the file exists to surface. Failed/cancelled operations
|
||||||
|
/// are always kept regardless of duration — a 1ms cancelled call is a churn signal, not noise.</summary>
|
||||||
|
public const int DefaultMinMs = 25;
|
||||||
|
|
||||||
|
/// <summary>One rollover file is kept (<c>.1</c>); at ~1000 lines/h a 4 MB cap holds weeks.</summary>
|
||||||
|
public const long DefaultMaxBytes = 4L * 1024 * 1024;
|
||||||
|
|
||||||
|
public static string DefaultPath =>
|
||||||
|
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
|
||||||
|
|
||||||
|
/// <summary>Kill switch for <see cref="Shared"/>: off by default, set
|
||||||
|
/// <c>CLAUDEDO_OP_TIMING=1</c> in the environment to turn the sink back on.</summary>
|
||||||
|
private static readonly bool DefaultEnabled =
|
||||||
|
Environment.GetEnvironmentVariable("CLAUDEDO_OP_TIMING") == "1";
|
||||||
|
|
||||||
|
// Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel,
|
||||||
|
// TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect
|
||||||
|
// it every test run appended its churn straight into the live app's NDJSON file at the real
|
||||||
|
// AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file
|
||||||
|
// for the whole test process before any test runs.
|
||||||
|
public static OperationTiming Shared { get; set; } = new(DefaultPath, enabled: DefaultEnabled);
|
||||||
|
|
||||||
|
private static readonly int ProcessId = Environment.ProcessId;
|
||||||
|
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly int _minMs;
|
||||||
|
private readonly long _maxBytes;
|
||||||
|
private readonly bool _enabled;
|
||||||
|
private readonly object _writeLock = new();
|
||||||
|
|
||||||
|
public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes, bool enabled = true)
|
||||||
|
{
|
||||||
|
_path = filePath;
|
||||||
|
_minMs = minMs;
|
||||||
|
_maxBytes = maxBytes;
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
|
||||||
|
{
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ms = (long)elapsed.TotalMilliseconds;
|
||||||
|
if (ok && ms < _minMs) return;
|
||||||
|
|
||||||
|
var line = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
ts = DateTime.UtcNow,
|
||||||
|
// Runs of the app interleave in one file; without the pid, a restart looks like
|
||||||
|
// concurrent work by a single instance.
|
||||||
|
pid = ProcessId,
|
||||||
|
kind,
|
||||||
|
op = operation,
|
||||||
|
ms,
|
||||||
|
ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
lock (_writeLock)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_path);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
Roll();
|
||||||
|
File.AppendAllText(_path, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A measurement must never disturb the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Roll()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path) || new FileInfo(_path).Length < _maxBytes) return;
|
||||||
|
var rolled = _path + ".1";
|
||||||
|
File.Delete(rolled);
|
||||||
|
File.Move(_path, rolled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Releases;
|
using ClaudeDo.Releases;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
@@ -15,10 +16,11 @@ public sealed partial class UpdateCheckService : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly IReleaseClient _releases;
|
private readonly IReleaseClient _releases;
|
||||||
|
|
||||||
|
public OperationStatus Op { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private bool _isUpdateAvailable;
|
[ObservableProperty] private bool _isUpdateAvailable;
|
||||||
[ObservableProperty] private string? _latestVersion;
|
[ObservableProperty] private string? _latestVersion;
|
||||||
[ObservableProperty] private string _currentVersion;
|
[ObservableProperty] private string _currentVersion;
|
||||||
[ObservableProperty] private bool _isChecking;
|
|
||||||
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
|
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
|
||||||
|
|
||||||
public UpdateCheckService(IReleaseClient releases, string currentVersion)
|
public UpdateCheckService(IReleaseClient releases, string currentVersion)
|
||||||
@@ -29,45 +31,39 @@ public sealed partial class UpdateCheckService : ObservableObject
|
|||||||
|
|
||||||
public async Task CheckNowAsync(CancellationToken ct)
|
public async Task CheckNowAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
IsChecking = true;
|
using var op = Op.Begin(Loc.T("ops.updateCheck.checking"));
|
||||||
|
|
||||||
|
GiteaRelease? rel;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GiteaRelease? rel;
|
rel = await _releases.GetLatestReleaseAsync(ct);
|
||||||
try
|
|
||||||
{
|
|
||||||
rel = await _releases.GetLatestReleaseAsync(ct);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
|
||||||
IsUpdateAvailable = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rel is null)
|
|
||||||
{
|
|
||||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
|
||||||
IsUpdateAvailable = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
|
|
||||||
var cmp = VersionComparer.Compare(latest, CurrentVersion);
|
|
||||||
if (cmp.IsNewer)
|
|
||||||
{
|
|
||||||
LatestVersion = latest;
|
|
||||||
IsUpdateAvailable = true;
|
|
||||||
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IsUpdateAvailable = false;
|
|
||||||
LastCheckStatus = UpdateCheckStatus.UpToDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
catch
|
||||||
{
|
{
|
||||||
IsChecking = false;
|
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rel is null)
|
||||||
|
{
|
||||||
|
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
|
||||||
|
var cmp = VersionComparer.Compare(latest, CurrentVersion);
|
||||||
|
if (cmp.IsNewer)
|
||||||
|
{
|
||||||
|
LatestVersion = latest;
|
||||||
|
IsUpdateAvailable = true;
|
||||||
|
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsUpdateAvailable = false;
|
||||||
|
LastCheckStatus = UpdateCheckStatus.UpToDate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string>? TaskUpdatedEvent;
|
public event Action<string>? TaskUpdatedEvent;
|
||||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
|
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||||
public event Action? ConnectionRestoredEvent;
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
@@ -64,6 +64,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||||
|
|
||||||
|
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||||
|
public event Action<string, string, int>? MergeProgressEvent;
|
||||||
|
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||||
@@ -151,9 +154,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||||
});
|
});
|
||||||
|
|
||||||
_hub.On<string, IReadOnlyList<string>>("HandoffRequested", (taskId, survivingTaskIds) =>
|
_hub.On<string, IReadOnlyList<string>, string>("HandoffRequested", (taskId, survivingTaskIds, nextPhase) =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds));
|
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
||||||
});
|
});
|
||||||
|
|
||||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||||
@@ -172,6 +175,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
|
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||||
|
MergeProgressEvent?.Invoke(opKey, phase, current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
|
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
|
||||||
@@ -270,23 +282,58 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
|
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
|
||||||
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
|
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
|
||||||
{
|
{
|
||||||
try { return await _hub.InvokeCoreAsync<T>(method, args); }
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _hub.InvokeCoreAsync<T>(method, args);
|
||||||
|
ok = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
catch { return default; }
|
catch { return default; }
|
||||||
|
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Times a void hub invoke without changing its exception/cancellation behavior.</summary>
|
||||||
|
private async Task InvokeTimedAsync(string method, Func<Task> invoke)
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await invoke();
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Times a hub invoke that returns a value, without changing its exception/cancellation behavior.</summary>
|
||||||
|
private async Task<T> InvokeTimedAsync<T>(string method, Func<Task<T>> invoke)
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await invoke();
|
||||||
|
ok = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunNowAsync(string taskId)
|
public async Task RunNowAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("RunNow", taskId);
|
await InvokeTimedAsync("RunNow", () => _hub.InvokeAsync("RunNow", taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
await InvokeTimedAsync("ContinueTask", () => _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
|
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
|
||||||
{
|
{
|
||||||
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
|
try { await InvokeTimedAsync<bool>("AnswerTaskQuestion", () => _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer)); }
|
||||||
catch { /* offline or already resolved — the UI clears optimistically */ }
|
catch { /* offline or already resolved — the UI clears optimistically */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,43 +342,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task ResetTaskAsync(string taskId)
|
public async Task ResetTaskAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("ResetTask", taskId);
|
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||||
{
|
{
|
||||||
return await _hub.InvokeAsync<MergeResultDto>(
|
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
|
||||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
=> InvokeTimedAsync<MergeResultDto>("StartConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch));
|
||||||
|
|
||||||
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||||
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
|
=> InvokeTimedAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", () => _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId));
|
||||||
|
|
||||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
=> InvokeTimedAsync("WriteConflictResolution", () => _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent));
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
|
=> InvokeTimedAsync<MergeResultDto>("ContinueConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId));
|
||||||
|
|
||||||
public Task AbortConflictMergeAsync(string taskId)
|
public Task AbortConflictMergeAsync(string taskId)
|
||||||
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
|
=> InvokeTimedAsync("AbortConflictMerge", () => _hub.InvokeAsync("AbortConflictMerge", taskId));
|
||||||
|
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||||
|
|
||||||
public async Task CancelTaskAsync(string taskId)
|
public async Task CancelTaskAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("CancelTask", taskId);
|
await InvokeTimedAsync("CancelTask", () => _hub.InvokeAsync("CancelTask", taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
|
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None);
|
await InvokeTimedAsync("DeleteTask", () => _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None));
|
||||||
return (true, null);
|
return (true, null);
|
||||||
}
|
}
|
||||||
catch (HubException ex)
|
catch (HubException ex)
|
||||||
@@ -347,7 +394,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task WakeQueueAsync()
|
public async Task WakeQueueAsync()
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("WakeQueue");
|
await InvokeTimedAsync("WakeQueue", () => _hub.InvokeAsync("WakeQueue"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<AgentInfo>> GetAgentsAsync()
|
public async Task<List<AgentInfo>> GetAgentsAsync()
|
||||||
@@ -355,17 +402,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task RefreshAgentsAsync()
|
public async Task RefreshAgentsAsync()
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("RefreshAgents");
|
await InvokeTimedAsync("RefreshAgents", () => _hub.InvokeAsync("RefreshAgents"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||||
=> await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
=> await InvokeTimedAsync<SeedResultDto>("RestoreDefaultAgents", () => _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents"));
|
||||||
|
|
||||||
private async Task SeedActiveTasksAsync()
|
private async Task SeedActiveTasksAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive");
|
var active = await InvokeTimedAsync<List<ActiveTaskDto>>("GetActive", () => _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive"));
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
ActiveTasks.Clear();
|
ActiveTasks.Clear();
|
||||||
@@ -395,18 +442,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("UpdateAppSettings", dto);
|
await InvokeTimedAsync("UpdateAppSettings", () => _hub.InvokeAsync("UpdateAppSettings", dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
||||||
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
|
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
|
||||||
|
|
||||||
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||||
=> await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
|
=> await InvokeTimedAsync<PrimeScheduleDto>("UpsertPrimeSchedule", () => _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto));
|
||||||
|
|
||||||
public async Task DeletePrimeScheduleAsync(Guid id)
|
public async Task DeletePrimeScheduleAsync(Guid id)
|
||||||
{
|
{
|
||||||
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); }
|
try { await InvokeTimedAsync("DeletePrimeSchedule", () => _hub.InvokeAsync("DeletePrimeSchedule", id)); }
|
||||||
catch { /* offline */ }
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,27 +463,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
|
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
|
||||||
|
|
||||||
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end)
|
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end)
|
||||||
=> _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end));
|
=> InvokeTimedAsync<string>("GenerateWeekReport", () => _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end)));
|
||||||
|
|
||||||
public Task<bool> RunDailyPrepNowAsync()
|
public Task<bool> RunDailyPrepNowAsync()
|
||||||
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
|
=> InvokeTimedAsync<bool>("RunDailyPrepNow", () => _hub.InvokeAsync<bool>("RunDailyPrepNow"));
|
||||||
|
|
||||||
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
public Task RefineTaskAsync(string taskId) => InvokeTimedAsync("RefineTask", () => _hub.InvokeAsync("RefineTask", taskId));
|
||||||
|
|
||||||
public Task ClearMyDayAsync()
|
public Task ClearMyDayAsync()
|
||||||
=> _hub.InvokeAsync("ClearMyDay");
|
=> InvokeTimedAsync("ClearMyDay", () => _hub.InvokeAsync("ClearMyDay"));
|
||||||
|
|
||||||
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
|
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
|
||||||
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
|
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
|
||||||
|
|
||||||
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
|
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
|
||||||
=> await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
|
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
|
||||||
|
|
||||||
public async Task UpdateDailyNoteAsync(string id, string text)
|
public async Task UpdateDailyNoteAsync(string id, string text)
|
||||||
=> await _hub.InvokeAsync("UpdateDailyNote", id, text);
|
=> await InvokeTimedAsync("UpdateDailyNote", () => _hub.InvokeAsync("UpdateDailyNote", id, text));
|
||||||
|
|
||||||
public async Task DeleteDailyNoteAsync(string id)
|
public async Task DeleteDailyNoteAsync(string id)
|
||||||
=> await _hub.InvokeAsync("DeleteDailyNote", id);
|
=> await InvokeTimedAsync("DeleteDailyNote", () => _hub.InvokeAsync("DeleteDailyNote", id));
|
||||||
|
|
||||||
public async Task<string> GetLastPrepLogAsync()
|
public async Task<string> GetLastPrepLogAsync()
|
||||||
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
||||||
@@ -449,12 +496,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task UpdateListAsync(UpdateListDto dto)
|
public async Task UpdateListAsync(UpdateListDto dto)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("UpdateList", dto);
|
await InvokeTimedAsync("UpdateList", () => _hub.InvokeAsync("UpdateList", dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
|
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("UpdateListConfig", dto);
|
await InvokeTimedAsync("UpdateListConfig", () => _hub.InvokeAsync("UpdateListConfig", dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ListConfigDto?> GetListConfigAsync(string listId)
|
public Task<ListConfigDto?> GetListConfigAsync(string listId)
|
||||||
@@ -462,36 +509,37 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
await InvokeTimedAsync("UpdateTaskAgentSettings", () => _hub.InvokeAsync("UpdateTaskAgentSettings", dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> GetRepoImportFoldersAsync()
|
public async Task<List<string>> GetRepoImportFoldersAsync()
|
||||||
=> await _hub.InvokeAsync<List<string>>("GetRepoImportFolders");
|
=> await InvokeTimedAsync<List<string>>("GetRepoImportFolders", () => _hub.InvokeAsync<List<string>>("GetRepoImportFolders"));
|
||||||
|
|
||||||
public Task SetRepoImportFoldersAsync(List<string> folders)
|
public Task SetRepoImportFoldersAsync(List<string> folders)
|
||||||
=> _hub.InvokeAsync("SetRepoImportFolders", folders);
|
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
|
||||||
|
|
||||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||||
|
|
||||||
public Task<List<string>> InstallSessionSkillAsync(string url)
|
public Task<List<string>> InstallSessionSkillAsync(string url)
|
||||||
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
|
=> InvokeTimedAsync<List<string>>("InstallSessionSkill", () => _hub.InvokeAsync<List<string>>("InstallSessionSkill", url));
|
||||||
|
|
||||||
public Task UpdateSessionSkillAsync(string sourceUrl)
|
public Task UpdateSessionSkillAsync(string sourceUrl)
|
||||||
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
|
=> InvokeTimedAsync("UpdateSessionSkill", () => _hub.InvokeAsync("UpdateSessionSkill", sourceUrl));
|
||||||
|
|
||||||
public Task RemoveSessionSkillAsync(string sourceUrl)
|
public Task RemoveSessionSkillAsync(string sourceUrl)
|
||||||
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
|
=> InvokeTimedAsync("RemoveSessionSkill", () => _hub.InvokeAsync("RemoveSessionSkill", sourceUrl));
|
||||||
|
|
||||||
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
public async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
var result = await InvokeTimedAsync<SetTaskStatusResultDto>("SetTaskStatus", () => _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString()));
|
||||||
|
return result?.BaseDirty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||||
{
|
{
|
||||||
LastApproveTarget = targetBranch;
|
LastApproveTarget = targetBranch;
|
||||||
return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||||
@@ -499,24 +547,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
|
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
|
await InvokeTimedAsync("RejectReviewToQueue", () => _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RejectReviewToIdleAsync(string taskId)
|
public async Task RejectReviewToIdleAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
|
await InvokeTimedAsync("RejectReviewToIdle", () => _hub.InvokeAsync("RejectReviewToIdle", taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CancelReviewAsync(string taskId)
|
public async Task CancelReviewAsync(string taskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("CancelReview", taskId);
|
await InvokeTimedAsync("CancelReview", () => _hub.InvokeAsync("CancelReview", taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||||
=> await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
=> await InvokeTimedAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", () => _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId));
|
||||||
|
|
||||||
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||||
=> await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
=> await InvokeTimedAsync<WorktreeResetDto>("ResetAllWorktrees", () => _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees"));
|
||||||
|
|
||||||
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||||||
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
|
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
|
||||||
@@ -526,7 +574,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
|
var ok = await InvokeTimedAsync<bool>("SetWorktreeState", () => _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState));
|
||||||
return (ok, null);
|
return (ok, null);
|
||||||
}
|
}
|
||||||
catch (HubException ex)
|
catch (HubException ex)
|
||||||
@@ -540,63 +588,63 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||||
=> await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
=> await InvokeTimedAsync<ForceRemoveResultDto>("ForceRemoveWorktree", () => _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId));
|
||||||
|
|
||||||
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
=> await InvokeTimedAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct));
|
||||||
|
|
||||||
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
=> await InvokeTimedAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct));
|
||||||
|
|
||||||
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
|
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
|
=> await InvokeTimedAsync("SubmitTaskForReview", () => _hub.InvokeAsync("SubmitTaskForReview", taskId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct));
|
||||||
|
|
||||||
public async Task<string> CreateMergeHelperTaskAsync(
|
public async Task<string> CreateMergeHelperTaskAsync(
|
||||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
|
=> await InvokeTimedAsync<string>("CreateMergeHelperTask", () => _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
||||||
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
||||||
|
|
||||||
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
|
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct));
|
||||||
|
|
||||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
=> await InvokeTimedAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", () => _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct));
|
||||||
|
|
||||||
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
|
=> await InvokeTimedAsync<int>("FinalizePlanningSessionAsync", () => _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct));
|
||||||
|
|
||||||
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
|
||||||
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
|
=> await InvokeTimedAsync<int>("GetPendingDraftCountAsync", () => _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct));
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
||||||
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
|
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
|
||||||
|
|
||||||
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||||
=> await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
=> await InvokeTimedAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", () => _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch));
|
||||||
|
|
||||||
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
|
await InvokeTimedAsync("ContinuePlanningMerge", () => _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AbortPlanningMergeAsync(string planningTaskId)
|
public async Task AbortPlanningMergeAsync(string planningTaskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
|
await InvokeTimedAsync("AbortPlanningMerge", () => _hub.InvokeAsync("AbortPlanningMerge", planningTaskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||||
@@ -605,20 +653,20 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
||||||
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
||||||
|
|
||||||
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
|
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
|
||||||
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
|
=> await InvokeTimedAsync("SetOnlineInboxConfig", () => _hub.InvokeAsync("SetOnlineInboxConfig", input));
|
||||||
|
|
||||||
public async Task SetOnlineInboxAuthAsync(string refreshToken)
|
public async Task SetOnlineInboxAuthAsync(string refreshToken)
|
||||||
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
|
=> await InvokeTimedAsync("SetOnlineInboxAuth", () => _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken));
|
||||||
|
|
||||||
public async Task ClearOnlineInboxAuthAsync()
|
public async Task ClearOnlineInboxAuthAsync()
|
||||||
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
|
=> await InvokeTimedAsync("ClearOnlineInboxAuth", () => _hub.InvokeAsync("ClearOnlineInboxAuth"));
|
||||||
|
|
||||||
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
|
||||||
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
|
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
|
||||||
@@ -670,7 +718,12 @@ public sealed record AppSettingsDto(
|
|||||||
List<ModelPresetDto>? ModelPresets = null,
|
List<ModelPresetDto>? ModelPresets = null,
|
||||||
int UsageGateFiveHourPct = 80,
|
int UsageGateFiveHourPct = 80,
|
||||||
int UsageGateSevenDayPct = 90,
|
int UsageGateSevenDayPct = 90,
|
||||||
int MaxTurnsCeiling = 80);
|
int MaxTurnsCeiling = 80,
|
||||||
|
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
|
||||||
|
int UsageThrottleFiveHourSoftPct = 50,
|
||||||
|
int UsageThrottleFiveHourHardPct = 65,
|
||||||
|
int UsageThrottleSevenDaySoftPct = 50,
|
||||||
|
int UsageThrottleSevenDayHardPct = 65);
|
||||||
|
|
||||||
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
// Per-model run defaults (effort + turn budget) edited in Settings → General.
|
||||||
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
|
||||||
@@ -685,12 +738,15 @@ public sealed record SessionSkillDto(
|
|||||||
public sealed record WorktreeCleanupDto(int Removed);
|
public sealed record WorktreeCleanupDto(int Removed);
|
||||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
|
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||||
|
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
public record MergeTargetsDto(
|
||||||
|
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
|
||||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||||
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, bool FindingsTracked = false);
|
||||||
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 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, string? VerifyCommand = null);
|
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
|
||||||
@@ -764,7 +820,13 @@ public sealed record UsageSnapshotDto(
|
|||||||
string? LastError,
|
string? LastError,
|
||||||
int ConfiguredSlots,
|
int ConfiguredSlots,
|
||||||
int EffectiveSlots,
|
int EffectiveSlots,
|
||||||
string? ThrottleBucket);
|
string? ThrottleBucket,
|
||||||
|
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
|
||||||
|
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
|
||||||
|
int ThrottleFiveHourSoftPct = 50,
|
||||||
|
int ThrottleFiveHourHardPct = 65,
|
||||||
|
int ThrottleSevenDaySoftPct = 50,
|
||||||
|
int ThrottleSevenDayHardPct = 65);
|
||||||
|
|
||||||
public sealed record ModelUsageRowDto(
|
public sealed record ModelUsageRowDto(
|
||||||
DateOnly Date,
|
DateOnly Date,
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||||
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
|
||||||
|
// to resolve -- surface why instead of quietly closing with no explanation.
|
||||||
|
if (start.Status is "blocked" or "untracked_collision")
|
||||||
Error = start.ErrorMessage;
|
Error = start.ErrorMessage;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public string SessionLabel => "claude-session";
|
public string SessionLabel => "claude-session";
|
||||||
|
|
||||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||||
@@ -299,7 +299,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))]
|
[NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))]
|
||||||
private string _reviewFeedback = "";
|
private string _reviewFeedback = "";
|
||||||
|
|
||||||
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback);
|
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback) && !RejectOp.IsRunning;
|
||||||
|
|
||||||
|
public OperationStatus ApproveOp { get; } = new();
|
||||||
|
public OperationStatus SubmitOp { get; } = new();
|
||||||
|
public OperationStatus RejectOp { get; } = new();
|
||||||
|
public OperationStatus ParkOp { get; } = new();
|
||||||
|
|
||||||
public DetailsIslandViewModel(
|
public DetailsIslandViewModel(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
@@ -330,6 +335,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
};
|
};
|
||||||
Prep = new PrepPanelViewModel(worker);
|
Prep = new PrepPanelViewModel(worker);
|
||||||
|
|
||||||
|
// OperationStatus.IsRunning is a nested ObservableObject property, so
|
||||||
|
// [NotifyCanExecuteChangedFor] on the command can't see it -- without this, the
|
||||||
|
// button stays clickable for the whole duration of the call.
|
||||||
|
ApproveOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ApproveReviewCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
SubmitOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
RejectOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasReviewFeedback));
|
||||||
|
RejectReviewCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ParkOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
|
||||||
Notes = new NotesEditorViewModel(_notesApi);
|
Notes = new NotesEditorViewModel(_notesApi);
|
||||||
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
|
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
|
||||||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||||||
@@ -549,7 +578,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public void ShowNotes()
|
public void ShowNotes()
|
||||||
{
|
{
|
||||||
Bind(null);
|
Bind(null, "notes");
|
||||||
IsPrepMode = false;
|
IsPrepMode = false;
|
||||||
IsNotesMode = true;
|
IsNotesMode = true;
|
||||||
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||||
@@ -557,13 +586,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public void ShowPrep()
|
public void ShowPrep()
|
||||||
{
|
{
|
||||||
Bind(null);
|
Bind(null, "prep");
|
||||||
IsNotesMode = false;
|
IsNotesMode = false;
|
||||||
IsPrepMode = true;
|
IsPrepMode = true;
|
||||||
_ = Prep.LoadLastPrepLogIfEmptyAsync();
|
_ = Prep.LoadLastPrepLogIfEmptyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Bind(TaskRowViewModel? row)
|
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
|
||||||
|
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
|
||||||
|
public void Bind(TaskRowViewModel? row, string source = "?")
|
||||||
{
|
{
|
||||||
IsNotesMode = false;
|
IsNotesMode = false;
|
||||||
IsPrepMode = false;
|
IsPrepMode = false;
|
||||||
@@ -601,11 +632,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = BindAsync(row, ct);
|
// A hand-driven task (manual reminder or a live ConPTY session) produces no streamed
|
||||||
|
// agent output, so the Output tab would open empty -- its work shows up as git changes.
|
||||||
|
SelectedTab = row.IsManual || row.HasInteractiveSession ? "git" : "output";
|
||||||
|
|
||||||
|
_ = BindAsync(row, ct, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct, string source)
|
||||||
{
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -697,12 +734,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Merge.RefreshMergePreviewAsync();
|
await Merge.RefreshMergePreviewAsync();
|
||||||
|
ok = true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
|
finally { OperationTiming.Shared.Record("db", $"DetailsIsland.BindAsync:{source}", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
|
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -713,6 +754,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
ok = true;
|
||||||
if (children.Count == 0) return;
|
if (children.Count == 0) return;
|
||||||
|
|
||||||
ChildOutcomes.Clear();
|
ChildOutcomes.Clear();
|
||||||
@@ -745,10 +787,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
|
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadChildOutcomesAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -758,6 +803,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
.Where(t => t.ParentTaskId == parentTaskId)
|
.Where(t => t.ParentTaskId == parentTaskId)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
ok = true;
|
||||||
|
|
||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
{
|
{
|
||||||
@@ -787,6 +833,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
|
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadPlanningChildrenAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
|
||||||
@@ -1093,12 +1140,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (Task == null) return;
|
if (Task == null) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||||
AgentState = "queued";
|
AgentState = "queued";
|
||||||
|
ReportBaseDirty(baseDirty);
|
||||||
}
|
}
|
||||||
catch { /* offline */ }
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
|
||||||
|
{
|
||||||
|
if (warning is null) return;
|
||||||
|
ErrorReported?.Invoke(Loc.T(
|
||||||
|
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
|
||||||
|
}
|
||||||
|
|
||||||
private bool CanEnqueue() =>
|
private bool CanEnqueue() =>
|
||||||
Task != null && _worker.IsConnected && IsIdle
|
Task != null && _worker.IsConnected && IsIdle
|
||||||
&& (!Task.IsChild || Task.ParentFinalized);
|
&& (!Task.IsChild || Task.ParentFinalized);
|
||||||
@@ -1164,8 +1219,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||||
AgentState = "queued";
|
AgentState = "queued";
|
||||||
|
ReportBaseDirty(baseDirty);
|
||||||
}
|
}
|
||||||
catch { /* offline */ }
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
@@ -1193,41 +1249,68 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
|
var taskId = Task.Id;
|
||||||
|
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
|
||||||
|
// Subscribed only for the duration of the call: the worker broadcasts to every
|
||||||
|
// client, and a VM left on this event would keep reacting to merges for other tasks.
|
||||||
|
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
|
||||||
|
{
|
||||||
|
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
|
||||||
|
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
|
||||||
|
}
|
||||||
|
_worker.MergeProgressEvent += OnMergeProgress;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||||
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||||
if (!hasChildren && result?.Status == "conflict")
|
if (!hasChildren && result?.Status == "conflict")
|
||||||
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||||
// The merge itself already landed; the verify command failed, so the task stayed
|
// 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.
|
// out of Done. Surface that instead of silently looking like nothing happened.
|
||||||
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
|
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
|
||||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||||
|
// Nothing landed -- the merge was refused before touching the repo because the
|
||||||
|
// branch would have overwritten an untracked file in the target working directory.
|
||||||
|
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (ShowErrorAsync != null)
|
if (ShowErrorAsync != null)
|
||||||
await ShowErrorAsync(ex.Message);
|
await ShowErrorAsync(ex.Message);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
|
||||||
|
private const string MergePhaseVerifying = "verifying";
|
||||||
|
|
||||||
|
private static string FormatElapsed(int seconds) =>
|
||||||
|
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
|
||||||
|
|
||||||
// Force the diff to have been opened before a merge can happen — but only when
|
// Force the diff to have been opened before a merge can happen — but only when
|
||||||
// there is actually something to inspect (a childless sandbox run with no worktree
|
// there is actually something to inspect (a childless sandbox run with no worktree
|
||||||
// has no diff, so it approves straight through).
|
// has no diff, so it approves straight through).
|
||||||
private bool CanApproveReview() =>
|
private bool CanApproveReview() =>
|
||||||
Task != null && _worker.IsConnected && IsWaitingForReview
|
Task != null && _worker.IsConnected && IsWaitingForReview
|
||||||
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
|
&& (!Merge.HasReviewableDiff || ReviewDiffViewed)
|
||||||
|
&& !ApproveOp.IsRunning;
|
||||||
|
|
||||||
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
|
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
|
||||||
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
|
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
|
||||||
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
|
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
|
||||||
public bool CanSubmitForReview =>
|
public bool CanSubmitForReview =>
|
||||||
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath);
|
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath)
|
||||||
|
&& !SubmitOp.IsRunning;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
|
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
|
||||||
private async System.Threading.Tasks.Task SubmitForReviewAsync()
|
private async System.Threading.Tasks.Task SubmitForReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
|
using var op = SubmitOp.Begin(Loc.T("ops.review.submitting"));
|
||||||
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1241,20 +1324,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
var feedback = ReviewFeedback;
|
var feedback = ReviewFeedback;
|
||||||
if (string.IsNullOrWhiteSpace(feedback)) return;
|
if (string.IsNullOrWhiteSpace(feedback)) return;
|
||||||
|
using var op = RejectOp.Begin(Loc.T("ops.review.rejecting"));
|
||||||
try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); }
|
try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); }
|
||||||
catch { /* stale review action; broadcast reconciles */ return; }
|
catch { /* stale review action; broadcast reconciles */ return; }
|
||||||
ReviewFeedback = "";
|
ReviewFeedback = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Park: set the task aside (back to Idle), keeping its worktree intact.
|
// Park: set the task aside (back to Idle), keeping its worktree intact.
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanParkReview))]
|
||||||
private async System.Threading.Tasks.Task ParkReviewAsync()
|
private async System.Threading.Tasks.Task ParkReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
|
using var op = ParkOp.Begin(Loc.T("ops.review.parking"));
|
||||||
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
|
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanParkReview() => Task != null && _worker.IsConnected && !ParkOp.IsRunning;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async System.Threading.Tasks.Task ResetReviewAsync()
|
private async System.Threading.Tasks.Task ResetReviewAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
// Reminder list: tasks created here default to manual.
|
// Reminder list: tasks created here default to manual.
|
||||||
[ObservableProperty] private bool _isManual;
|
[ObservableProperty] private bool _isManual;
|
||||||
|
// Whether this list's .claudedo/ findings folder is committed with the repo (ListEntity.FindingsTracked).
|
||||||
|
[ObservableProperty] private bool _findingsTracked;
|
||||||
[ObservableProperty] private bool _dropHintAbove;
|
[ObservableProperty] private bool _dropHintAbove;
|
||||||
[ObservableProperty] private bool _dropHintBelow;
|
[ObservableProperty] private bool _dropHintBelow;
|
||||||
// Set while a dragged task hovers over this row as a move target — distinct from
|
// Set while a dragged task hovers over this row as a move target — distinct from
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (row is null || Dialogs is null || _services is null) return;
|
if (row is null || Dialogs is null || _services is null) return;
|
||||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
|
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked);
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
if (vm.Deleted) await LoadAsync();
|
if (vm.Deleted) await LoadAsync();
|
||||||
else await RefreshRowAsync(row.Id);
|
else await RefreshRowAsync(row.Id);
|
||||||
@@ -121,20 +121,44 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenInTerminal(ListNavItemViewModel? row)
|
private void OpenFindings(ListNavItemViewModel? row)
|
||||||
{
|
{
|
||||||
var dir = row?.WorkingDir;
|
var dir = row?.WorkingDir;
|
||||||
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
|
if (string.IsNullOrWhiteSpace(dir)) return;
|
||||||
ForegroundHelper.AllowAny();
|
var findings = System.IO.Path.Combine(dir, ".claudedo");
|
||||||
|
if (!System.IO.Directory.Exists(findings)) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "wt.exe",
|
FileName = findings,
|
||||||
Arguments = $"-d \"{dir}\"",
|
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenInTerminal(ListNavItemViewModel? row)
|
||||||
|
{
|
||||||
|
var dir = row?.WorkingDir;
|
||||||
|
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
|
||||||
|
// Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as
|
||||||
|
// C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator).
|
||||||
|
// Rows loaded before ListRepository normalized on write can still carry one.
|
||||||
|
dir = Paths.TrimTrailingSeparator(dir)!;
|
||||||
|
ForegroundHelper.AllowAny();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "wt.exe",
|
||||||
|
UseShellExecute = true,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("-d");
|
||||||
|
psi.ArgumentList.Add(dir);
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Windows Terminal not installed — fall back to a plain console at the directory.
|
// Windows Terminal not installed — fall back to a plain console at the directory.
|
||||||
@@ -231,29 +255,37 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
};
|
};
|
||||||
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var lists = new ListRepository(ctx);
|
var ok = false;
|
||||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
try
|
||||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
|
||||||
int idx = 0;
|
|
||||||
foreach (var l in await lists.GetAllAsync(ct))
|
|
||||||
{
|
{
|
||||||
if (seedNames.Contains(l.Name)) continue;
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var item = new ListNavItemViewModel
|
var lists = new ListRepository(ctx);
|
||||||
|
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||||
|
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||||
|
int idx = 0;
|
||||||
|
foreach (var l in await lists.GetAllAsync(ct))
|
||||||
{
|
{
|
||||||
Id = $"user:{l.Id}",
|
if (seedNames.Contains(l.Name)) continue;
|
||||||
Name = l.Name,
|
var item = new ListNavItemViewModel
|
||||||
Kind = ListKind.User,
|
{
|
||||||
IconKey = "Folder",
|
Id = $"user:{l.Id}",
|
||||||
DotColorKey = dotColors[idx % dotColors.Length],
|
Name = l.Name,
|
||||||
WorkingDir = l.WorkingDir,
|
Kind = ListKind.User,
|
||||||
DefaultCommitType = l.DefaultCommitType,
|
IconKey = "Folder",
|
||||||
IsManual = l.IsManual,
|
DotColorKey = dotColors[idx % dotColors.Length],
|
||||||
};
|
WorkingDir = l.WorkingDir,
|
||||||
Items.Add(item);
|
DefaultCommitType = l.DefaultCommitType,
|
||||||
UserLists.Add(item);
|
IsManual = l.IsManual,
|
||||||
idx++;
|
FindingsTracked = l.FindingsTracked,
|
||||||
|
};
|
||||||
|
Items.Add(item);
|
||||||
|
UserLists.Add(item);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
ok = true;
|
||||||
}
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
|
||||||
|
|
||||||
await RefreshCountsAsync(ct);
|
await RefreshCountsAsync(ct);
|
||||||
SelectedList = Items.FirstOrDefault();
|
SelectedList = Items.FirstOrDefault();
|
||||||
@@ -261,6 +293,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
public async Task RefreshCountsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
@@ -281,9 +315,11 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var filter = _filters.Resolve(item.Id);
|
var filter = _filters.Resolve(item.Id);
|
||||||
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||||
}
|
}
|
||||||
|
ok = true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { throw; }
|
catch (OperationCanceledException) { throw; }
|
||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -323,7 +359,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (Dialogs is not null && _services is not null)
|
if (Dialogs is not null && _services is not null)
|
||||||
{
|
{
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual);
|
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked);
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
if (vm.Deleted) await LoadAsync();
|
if (vm.Deleted) await LoadAsync();
|
||||||
else await RefreshRowAsync(item.Id);
|
else await RefreshRowAsync(item.Id);
|
||||||
@@ -357,9 +393,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
MoveWithinCollection(UserLists, source, target, placeBelow);
|
MoveWithinCollection(UserLists, source, target, placeBelow);
|
||||||
|
|
||||||
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
|
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var lists = new ListRepository(ctx);
|
var ok = false;
|
||||||
await lists.ReorderAsync(orderedIds);
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
await lists.ReorderAsync(orderedIds);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MoveWithinCollection(
|
private static void MoveWithinCollection(
|
||||||
@@ -404,6 +447,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.WorkingDir = entity.WorkingDir;
|
row.WorkingDir = entity.WorkingDir;
|
||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
row.IsManual = entity.IsManual;
|
row.IsManual = entity.IsManual;
|
||||||
|
row.FindingsTracked = entity.FindingsTracked;
|
||||||
}
|
}
|
||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -27,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||||
[ObservableProperty] private string? _selectedMergeTarget;
|
[ObservableProperty] private string? _selectedMergeTarget;
|
||||||
|
|
||||||
|
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
|
||||||
|
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
||||||
|
public OperationStatus PreviewOp { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
private string _mergePreviewText = "";
|
private string _mergePreviewText = "";
|
||||||
@@ -118,6 +123,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
var capturedTaskId = TaskId;
|
var capturedTaskId = TaskId;
|
||||||
var capturedTarget = SelectedMergeTarget;
|
var capturedTarget = SelectedMergeTarget;
|
||||||
|
using var op = PreviewOp.Begin(Loc.T("ops.review.previewing"));
|
||||||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
||||||
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Ui.Helpers;
|
using ClaudeDo.Ui.Helpers;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -19,11 +20,23 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty] private bool _isPrepRunning;
|
[ObservableProperty] private bool _isPrepRunning;
|
||||||
|
|
||||||
|
// Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning
|
||||||
|
// stays false until that event arrives, so without this the button click looks like a no-op.
|
||||||
|
public OperationStatus PrepOperation { get; } = new();
|
||||||
|
|
||||||
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
||||||
|
|
||||||
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||||||
|
|
||||||
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
// The long-running broadcast-driven state (IsPrepRunning) and the click-to-event gap
|
||||||
|
// (PrepOperation.IsRunning) both need to block a second click.
|
||||||
|
public bool IsPlanDayEnabled => !IsPrepRunning && !PrepOperation.IsRunning;
|
||||||
|
|
||||||
|
partial void OnIsPrepRunningChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||||
|
OnPropertyChanged(nameof(IsPlanDayEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
public PrepPanelViewModel(IWorkerClient worker)
|
public PrepPanelViewModel(IWorkerClient worker)
|
||||||
{
|
{
|
||||||
@@ -37,6 +50,15 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
||||||
|
|
||||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||||
|
|
||||||
|
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
|
||||||
|
// into it, so IsRunning changes are relayed by hand.
|
||||||
|
PrepOperation.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||||
|
PlanDayCommand.NotifyCanExecuteChanged();
|
||||||
|
OnPropertyChanged(nameof(IsPlanDayEnabled));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -46,9 +68,12 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanPlanDay() => IsPlanDayEnabled;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanPlanDay))]
|
||||||
private async System.Threading.Tasks.Task PlanDayAsync()
|
private async System.Threading.Tasks.Task PlanDayAsync()
|
||||||
{
|
{
|
||||||
|
using var op = PrepOperation.Begin(Loc.T("ops.reports.runningDailyPrep"));
|
||||||
try { await _worker.RunDailyPrepNowAsync(); }
|
try { await _worker.RunDailyPrepNowAsync(); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
public sealed partial class TaskRowViewModel : ViewModelBase
|
public sealed partial class TaskRowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
|
[ObservableProperty] private int _number;
|
||||||
[ObservableProperty] private string _title = "";
|
[ObservableProperty] private string _title = "";
|
||||||
[ObservableProperty] private string _listName = "";
|
[ObservableProperty] private string _listName = "";
|
||||||
[ObservableProperty] private bool _done;
|
[ObservableProperty] private bool _done;
|
||||||
@@ -27,13 +28,25 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string? _parentTaskId;
|
[ObservableProperty] private string? _parentTaskId;
|
||||||
[ObservableProperty] private string? _createdBy;
|
[ObservableProperty] private string? _createdBy;
|
||||||
[ObservableProperty] private string? _blockedByTaskId;
|
[ObservableProperty] private string? _blockedByTaskId;
|
||||||
|
[ObservableProperty] private string? _dependsOnTaskId;
|
||||||
[ObservableProperty] private bool _isExpanded = true;
|
[ObservableProperty] private bool _isExpanded = true;
|
||||||
[ObservableProperty] private bool _hasPlanningChildren;
|
[ObservableProperty] private bool _hasPlanningChildren;
|
||||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||||
[ObservableProperty] private bool _showListChip = true;
|
[ObservableProperty] private bool _showListChip = true;
|
||||||
[ObservableProperty] private bool _parentFinalized;
|
[ObservableProperty] private bool _parentFinalized;
|
||||||
[ObservableProperty] private bool _parentInView = true;
|
[ObservableProperty] private bool _parentInView = true;
|
||||||
|
// The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup
|
||||||
|
// (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how
|
||||||
|
// ParentInView is assigned by the same pass rather than derived locally.
|
||||||
|
[ObservableProperty] private bool _showAsChainMember;
|
||||||
|
[ObservableProperty] private int? _chainStep;
|
||||||
|
[ObservableProperty] private string? _chainAfterLabel;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
|
// Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a
|
||||||
|
// Failed task that predates this field. Null on every other status.
|
||||||
|
[ObservableProperty] private string? _failureReason;
|
||||||
|
[ObservableProperty] private int? _failureTurnsUsed;
|
||||||
|
[ObservableProperty] private int? _failureMaxTurns;
|
||||||
[ObservableProperty] private bool _isRefining;
|
[ObservableProperty] private bool _isRefining;
|
||||||
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
|
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
|
||||||
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
|
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
|
||||||
@@ -45,6 +58,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||||
[ObservableProperty] private bool _isDragging;
|
[ObservableProperty] private bool _isDragging;
|
||||||
|
|
||||||
|
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
|
||||||
|
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
|
||||||
|
public bool IsDropTarget => DropHintAbove || DropHintBelow;
|
||||||
|
|
||||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
|
||||||
&& !IsRefining && !IsManual;
|
&& !IsRefining && !IsManual;
|
||||||
|
|
||||||
@@ -56,6 +73,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public int StepsCount { get; init; }
|
public int StepsCount { get; init; }
|
||||||
public int StepsCompleted { get; init; }
|
public int StepsCompleted { get; init; }
|
||||||
|
|
||||||
|
// Number is 0 for rows created outside TaskNumberAllocator (test seeds, a future import
|
||||||
|
// path); a bare "#0" would be meaningless, so hide the badge entirely below 1.
|
||||||
|
public bool ShowNumberBadge => Number > 0;
|
||||||
|
|
||||||
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
|
||||||
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||||
@@ -124,15 +145,33 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
? "1 roadblock reported during the run — see details"
|
? "1 roadblock reported during the run — see details"
|
||||||
: $"{RoadblockCount} roadblocks reported during the run — see details";
|
: $"{RoadblockCount} roadblocks reported during the run — see details";
|
||||||
|
|
||||||
|
// True for every Failed task, even one that predates this field — FailureReasonLabel then
|
||||||
|
// falls back to "unknown" instead of leaving the tooltip blank.
|
||||||
|
public bool HasFailureReason => Status == TaskStatus.Failed;
|
||||||
|
public string FailureReasonLabel => FailureReason switch
|
||||||
|
{
|
||||||
|
"max_turns" => Loc.T("vm.failureReason.maxTurns"),
|
||||||
|
"timeout" => Loc.T("vm.failureReason.timeout"),
|
||||||
|
"cancelled" => Loc.T("vm.failureReason.cancelled"),
|
||||||
|
"error" => Loc.T("vm.failureReason.error"),
|
||||||
|
_ => Loc.T("vm.failureReason.unknown"),
|
||||||
|
};
|
||||||
|
// max_turns gets the actionable detail (turns used/configured) since that's the one case
|
||||||
|
// where the fix is "raise maxTurns and continue_task", not "reset and re-run".
|
||||||
|
public string? FailureReasonTooltip => !HasFailureReason ? null
|
||||||
|
: FailureReason == "max_turns" && FailureTurnsUsed is { } used && FailureMaxTurns is { } max
|
||||||
|
? Loc.T("vm.failureReasonTooltip.maxTurns", used, max)
|
||||||
|
: FailureReasonLabel;
|
||||||
|
|
||||||
|
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
|
||||||
|
// otherwise a Failed task's reason, otherwise no tooltip.
|
||||||
|
public string? StatusChipTooltip
|
||||||
|
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip;
|
||||||
|
|
||||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
||||||
|
|
||||||
// Null on a plain status chip so no tooltip shows there — the chip is only actionable
|
|
||||||
// while a session is open.
|
|
||||||
public string? InteractiveChipTooltip
|
|
||||||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null;
|
|
||||||
|
|
||||||
public string StatusLabel
|
public string StatusLabel
|
||||||
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
||||||
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||||
@@ -179,6 +218,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsParked));
|
OnPropertyChanged(nameof(IsParked));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(IsWaiting));
|
OnPropertyChanged(nameof(IsWaiting));
|
||||||
|
OnPropertyChanged(nameof(HasFailureReason));
|
||||||
|
OnPropertyChanged(nameof(FailureReasonTooltip));
|
||||||
|
OnPropertyChanged(nameof(StatusChipTooltip));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(IsPlanned));
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
@@ -245,10 +287,21 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(StatusChipClass));
|
OnPropertyChanged(nameof(StatusChipClass));
|
||||||
OnPropertyChanged(nameof(StatusLabel));
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
OnPropertyChanged(nameof(ShowStatusChip));
|
OnPropertyChanged(nameof(ShowStatusChip));
|
||||||
OnPropertyChanged(nameof(InteractiveChipTooltip));
|
OnPropertyChanged(nameof(StatusChipTooltip));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnFailureReasonChanged(string? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasFailureReason));
|
||||||
|
OnPropertyChanged(nameof(FailureReasonLabel));
|
||||||
|
OnPropertyChanged(nameof(FailureReasonTooltip));
|
||||||
|
OnPropertyChanged(nameof(StatusChipTooltip));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnFailureTurnsUsedChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
|
||||||
|
partial void OnFailureMaxTurnsChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
|
||||||
|
|
||||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
@@ -269,6 +322,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanQueuePlan));
|
OnPropertyChanged(nameof(CanQueuePlan));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnNumberChanged(int value) => OnPropertyChanged(nameof(ShowNumberBadge));
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||||
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
|
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
|
||||||
{
|
{
|
||||||
@@ -289,6 +343,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
|
||||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
|
||||||
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
|
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
|
||||||
|
partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
|
||||||
|
partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
|
||||||
|
|
||||||
public void RefreshLocalized()
|
public void RefreshLocalized()
|
||||||
{
|
{
|
||||||
@@ -308,6 +364,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public void UpdateFromEntity(TaskEntity t)
|
public void UpdateFromEntity(TaskEntity t)
|
||||||
{
|
{
|
||||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||||
|
Number = t.Number;
|
||||||
Title = t.Title;
|
Title = t.Title;
|
||||||
ListName = t.List?.Name ?? "";
|
ListName = t.List?.Name ?? "";
|
||||||
Done = t.Status == TaskStatus.Done;
|
Done = t.Status == TaskStatus.Done;
|
||||||
@@ -325,7 +382,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
ParentTaskId = t.ParentTaskId;
|
ParentTaskId = t.ParentTaskId;
|
||||||
CreatedBy = t.CreatedBy;
|
CreatedBy = t.CreatedBy;
|
||||||
BlockedByTaskId = t.BlockedByTaskId;
|
BlockedByTaskId = t.BlockedByTaskId;
|
||||||
|
DependsOnTaskId = t.DependsOnTaskId;
|
||||||
RoadblockCount = t.RoadblockCount;
|
RoadblockCount = t.RoadblockCount;
|
||||||
|
FailureReason = t.FailureReason;
|
||||||
|
FailureTurnsUsed = t.FailureTurnsUsed;
|
||||||
|
FailureMaxTurns = t.FailureMaxTurns;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
@@ -14,6 +15,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
/// <summary>A group header entry in <see cref="TasksIslandViewModel.Rows"/> — the flat list's
|
||||||
|
/// other union member alongside <see cref="TaskRowViewModel"/>. Instances are persistent (one per
|
||||||
|
/// section, mutated in place by <see cref="TasksIslandViewModel.Regroup"/>) so an unchanged header
|
||||||
|
/// keeps its identity across a reconcile.</summary>
|
||||||
|
public sealed partial class HeaderRow : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty] private string _label = "";
|
||||||
|
[ObservableProperty] private int _count;
|
||||||
|
public bool IsOverdue { get; init; }
|
||||||
|
public IRelayCommand? ActionCommand { get; init; }
|
||||||
|
public bool HasAction => ActionCommand is not null;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
@@ -25,8 +39,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// pick the flag up (see SyncInteractiveSessions).
|
// pick the flag up (see SyncInteractiveSessions).
|
||||||
private readonly HashSet<string> _interactiveSessionIds = new();
|
private readonly HashSet<string> _interactiveSessionIds = new();
|
||||||
private static readonly TaskListFilterRegistry _filters = new();
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
|
||||||
|
// one task can be in flight at once. Only the newest may write to the row.
|
||||||
|
private readonly Dictionary<string, long> _deltaSeq = new();
|
||||||
|
private long _deltaCounter;
|
||||||
|
// Phase 3 reconcile tick — see the block at the end of this class.
|
||||||
|
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
|
|
||||||
|
/// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
|
||||||
|
/// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
|
||||||
|
public string SelectionSource { get; private set; } = "init";
|
||||||
|
|
||||||
|
/// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
|
||||||
|
/// assigning the property, otherwise the bind is logged as "?" .</summary>
|
||||||
|
public void SelectFrom(TaskRowViewModel? row, string source)
|
||||||
|
{
|
||||||
|
SelectionSource = source;
|
||||||
|
SelectedTask = row;
|
||||||
|
}
|
||||||
|
|
||||||
public event EventHandler? FocusAddTaskRequested;
|
public event EventHandler? FocusAddTaskRequested;
|
||||||
public event EventHandler? TasksChanged;
|
public event EventHandler? TasksChanged;
|
||||||
public event Action? NotesRequested;
|
public event Action? NotesRequested;
|
||||||
@@ -37,7 +70,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenNotes()
|
private void OpenNotes()
|
||||||
{
|
{
|
||||||
SelectedTask = null;
|
SelectFrom(null, "notes");
|
||||||
NotesRequested?.Invoke();
|
NotesRequested?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +86,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
|
||||||
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
|
|
||||||
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
|
/// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
|
||||||
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
|
/// (<see cref="HeaderRow"/>) interleaved with visible <see cref="TaskRowViewModel"/> rows, in
|
||||||
|
/// display order. Reconciled granularly by <see cref="Regroup"/> — never Clear+Add.</summary>
|
||||||
|
public ObservableCollection<object> Rows { get; } = new();
|
||||||
|
|
||||||
|
private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true };
|
||||||
|
private readonly HeaderRow _openHeaderRow = new();
|
||||||
|
private readonly HeaderRow _completedHeaderRow;
|
||||||
|
|
||||||
[ObservableProperty] private string _newTaskTitle = "";
|
[ObservableProperty] private string _newTaskTitle = "";
|
||||||
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
[ObservableProperty] private TaskRowViewModel? _selectedTask;
|
||||||
@@ -75,6 +114,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[ObservableProperty] private bool _isLetClaudeVisible;
|
[ObservableProperty] private bool _isLetClaudeVisible;
|
||||||
[ObservableProperty] private bool _isQuickClaudeVisible;
|
[ObservableProperty] private bool _isQuickClaudeVisible;
|
||||||
|
|
||||||
|
// Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered
|
||||||
|
// from a context menu that closes the instant a click lands, so there is no per-row surface
|
||||||
|
// left standing to anchor an indicator to. The island header is the nearest surface still
|
||||||
|
// visible after the menu closes.
|
||||||
|
public OperationStatus PlanningOperation { get; } = new();
|
||||||
|
|
||||||
public event EventHandler? LetClaudeHandleRequested;
|
public event EventHandler? LetClaudeHandleRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -111,6 +156,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
|
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
|
||||||
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
||||||
if (_worker is not null)
|
if (_worker is not null)
|
||||||
{
|
{
|
||||||
@@ -123,18 +169,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
_langChangedHandler = (_, _) => RefreshLocalizedText();
|
_langChangedHandler = (_, _) => RefreshLocalizedText();
|
||||||
Loc.LanguageChanged += _langChangedHandler;
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
|
_reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
||||||
|
_reconcileTimer.Start();
|
||||||
|
|
||||||
|
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
|
||||||
|
// into it, so IsRunning changes are relayed by hand.
|
||||||
|
PlanningOperation.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||||
|
QueuePlanningSubtasksCommand.NotifyCanExecuteChanged();
|
||||||
|
FinalizePlanningSessionCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Loc.LanguageChanged -= _langChangedHandler;
|
Loc.LanguageChanged -= _langChangedHandler;
|
||||||
|
_reconcileTimer.Stop();
|
||||||
|
_reconcileTimer.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
{
|
{
|
||||||
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
||||||
foreach (var row in Items) row.RefreshLocalized();
|
foreach (var row in Items) row.RefreshLocalized();
|
||||||
foreach (var row in CompletedItems) row.RefreshLocalized();
|
// The section headers are now Rows entries whose Label is a resolved string, not a live
|
||||||
|
// {loc:Tr} binding in the view — without re-emitting them they keep the old language.
|
||||||
|
Regroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnWorkerListUpdated(string listId)
|
private async void OnWorkerListUpdated(string listId)
|
||||||
@@ -161,6 +222,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async void OnWorkerTaskUpdated(string taskId)
|
private async void OnWorkerTaskUpdated(string taskId)
|
||||||
|
=> await RefreshTaskFromWorkerAsync(taskId);
|
||||||
|
|
||||||
|
// Awaitable so tests can drive it deterministically. One retry, then a full reload:
|
||||||
|
// a swallowed exception here used to leave the row on a stale status permanently.
|
||||||
|
internal async Task RefreshTaskFromWorkerAsync(string taskId)
|
||||||
{
|
{
|
||||||
var list = _currentList;
|
var list = _currentList;
|
||||||
if (list is null) return;
|
if (list is null) return;
|
||||||
@@ -174,54 +240,79 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seq = ++_deltaCounter;
|
||||||
|
_deltaSeq[taskId] = seq;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
var entity = await db.Tasks
|
|
||||||
.Include(t => t.List)
|
|
||||||
.Include(t => t.Worktree)
|
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
|
||||||
|
|
||||||
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
|
|
||||||
// changes its children's derived state — finalize flips them Draft→Planned, discard
|
|
||||||
// deletes them. The delta path below only touches the parent row and never recomputes
|
|
||||||
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
|
|
||||||
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
|
|
||||||
if (entity is not null &&
|
|
||||||
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
|
|
||||||
{
|
|
||||||
LoadForList(list);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
|
||||||
|
|
||||||
if (entity is null)
|
|
||||||
{
|
|
||||||
if (existing is not null) Items.Remove(existing);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var matches = TaskMatchesList(entity, list);
|
|
||||||
if (existing is not null && matches) existing.UpdateFromEntity(entity);
|
|
||||||
else if (existing is not null) Items.Remove(existing);
|
|
||||||
else if (matches) { LoadForList(list); return; }
|
|
||||||
else return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
|
|
||||||
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
|
|
||||||
{
|
|
||||||
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
|
|
||||||
if (parent is not null)
|
|
||||||
parent.HasQueuedSubtasks = Items.Any(r =>
|
|
||||||
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
|
|
||||||
}
|
|
||||||
|
|
||||||
Regroup();
|
|
||||||
UpdateSubtitle();
|
|
||||||
}
|
}
|
||||||
catch { }
|
catch (Exception first)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine(
|
||||||
|
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyDeltaAsync(taskId, list, seq);
|
||||||
|
}
|
||||||
|
catch (Exception second)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine(
|
||||||
|
$"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
|
||||||
|
LoadForList(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
|
||||||
|
{
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await db.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
|
||||||
|
// A newer refresh for this task started while we were reading — its result is fresher.
|
||||||
|
if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;
|
||||||
|
|
||||||
|
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
|
||||||
|
// changes its children's derived state — finalize flips them Draft→Planned, discard
|
||||||
|
// deletes them. The delta path below only touches the parent row and never recomputes
|
||||||
|
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
|
||||||
|
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
|
||||||
|
if (entity is not null &&
|
||||||
|
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
|
||||||
|
{
|
||||||
|
LoadForList(list);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
if (existing is not null) Items.Remove(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var matches = TaskMatchesList(entity, list);
|
||||||
|
if (existing is not null && matches) existing.UpdateFromEntity(entity);
|
||||||
|
else if (existing is not null) Items.Remove(existing);
|
||||||
|
else if (matches) { LoadForList(list); return; }
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
|
||||||
|
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
|
||||||
|
{
|
||||||
|
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
|
||||||
|
if (parent is not null)
|
||||||
|
parent.HasQueuedSubtasks = Items.Any(r =>
|
||||||
|
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
|
||||||
|
}
|
||||||
|
|
||||||
|
Regroup();
|
||||||
|
UpdateSubtitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
|
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
|
||||||
@@ -275,15 +366,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
|
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
|
||||||
|
|
||||||
Items.Clear();
|
Items.Clear();
|
||||||
OverdueItems.Clear();
|
Rows.Clear();
|
||||||
OpenItems.Clear();
|
|
||||||
CompletedItems.Clear();
|
|
||||||
HasOverdue = false;
|
HasOverdue = false;
|
||||||
HasOpen = false;
|
HasOpen = false;
|
||||||
HasCompleted = false;
|
HasCompleted = false;
|
||||||
ShowOpenLabel = false;
|
ShowOpenLabel = false;
|
||||||
ShowNotesRow = false;
|
ShowNotesRow = false;
|
||||||
if (listChanged) SelectedTask = null;
|
if (listChanged) SelectFrom(null, "list-change");
|
||||||
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||||
|
|
||||||
HeaderTitle = list.Name;
|
HeaderTitle = list.Name;
|
||||||
@@ -299,29 +388,58 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async Task LoadForListAsync(
|
private async Task LoadForListAsync(
|
||||||
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
|
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
|
||||||
{
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var ok = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var all = await db.Tasks
|
var filter = _filters.Resolve(list.Id);
|
||||||
.Include(t => t.List)
|
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
|
||||||
.Include(t => t.Worktree)
|
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
var filteredList = filter is null
|
||||||
.ToListAsync(ct);
|
? new List<TaskEntity>()
|
||||||
|
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var filter = _filters.Resolve(list.Id);
|
if (filter is not null)
|
||||||
var filteredList = filter is null
|
|
||||||
? new List<TaskEntity>()
|
|
||||||
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
|
|
||||||
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
|
||||||
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
|
||||||
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
|
|
||||||
{
|
{
|
||||||
if (existingIds.Add(c.Id))
|
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
|
||||||
filteredList.Add(c);
|
// child): only fetch candidates that could plausibly qualify — parents of tasks we already
|
||||||
|
// matched — instead of scanning the whole table.
|
||||||
|
var candidateParentIds = filteredList
|
||||||
|
.Where(t => t.ParentTaskId != null)
|
||||||
|
.Select(t => t.ParentTaskId!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (candidateParentIds.Count > 0)
|
||||||
|
{
|
||||||
|
var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
|
||||||
|
var parentCandidates = await baseQuery
|
||||||
|
.Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
foreach (var p in parentCandidates)
|
||||||
|
if (filter.MatchesAsContext(p, filteredList))
|
||||||
|
filteredList.Add(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Pull in every child of an already-matched top-level row, regardless of whether the child
|
||||||
|
// itself matches the filter, so subtasks render nested under their parent.
|
||||||
|
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
|
||||||
|
if (topIds.Count > 0)
|
||||||
|
{
|
||||||
|
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
|
||||||
|
var extraChildren = await baseQuery
|
||||||
|
.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
filteredList.AddRange(extraChildren);
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
|
||||||
|
|
||||||
var showListChip = list.Kind == ListKind.Virtual;
|
var showListChip = list.Kind == ListKind.Virtual;
|
||||||
foreach (var t in filteredList)
|
foreach (var t in filteredList)
|
||||||
{
|
{
|
||||||
@@ -372,8 +490,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
|
ok = true;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
|
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Regroup()
|
internal void Regroup()
|
||||||
@@ -398,7 +518,55 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
r.IsExpanded = saved;
|
r.IsExpanded = saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
|
var (overdue, open, completed) = ClassifyItems();
|
||||||
|
|
||||||
|
HasOverdue = overdue.Count > 0;
|
||||||
|
HasOpen = open.Count > 0;
|
||||||
|
HasCompleted = completed.Count > 0;
|
||||||
|
ShowOpenLabel = HasOpen && HasOverdue;
|
||||||
|
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count);
|
||||||
|
|
||||||
|
// Build the flat Rows target: header + rows per section, omitting a section entirely
|
||||||
|
// when it has nothing to show (no header, no rows) instead of hiding it via IsVisible.
|
||||||
|
var target = new List<object>();
|
||||||
|
if (HasOverdue)
|
||||||
|
{
|
||||||
|
_overdueHeaderRow.Label = Loc.T("tasks.overdue");
|
||||||
|
_overdueHeaderRow.Count = overdue.Count;
|
||||||
|
target.Add(_overdueHeaderRow);
|
||||||
|
target.AddRange(overdue);
|
||||||
|
}
|
||||||
|
if (HasOpen)
|
||||||
|
{
|
||||||
|
if (ShowOpenLabel)
|
||||||
|
{
|
||||||
|
_openHeaderRow.Label = Loc.T("tasks.tasks");
|
||||||
|
_openHeaderRow.Count = open.Count;
|
||||||
|
target.Add(_openHeaderRow);
|
||||||
|
}
|
||||||
|
target.AddRange(open);
|
||||||
|
}
|
||||||
|
if (HasCompleted && IsShowingCompleted)
|
||||||
|
{
|
||||||
|
_completedHeaderRow.Label = CompletedHeader;
|
||||||
|
_completedHeaderRow.Count = completed.Count;
|
||||||
|
target.Add(_completedHeaderRow);
|
||||||
|
target.AddRange(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling
|
||||||
|
// a parent only touches its own child rows — the ListBox keeps every unchanged container
|
||||||
|
// instead of tearing the whole list down on a Reset.
|
||||||
|
SyncCollection(Rows, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children,
|
||||||
|
// orphans flagged so they render flat) and partitions it into Overdue/Open/Completed. Pure
|
||||||
|
// with respect to chain state (ShowAsChainMember/ChainStep/ChainAfterLabel) — callers that
|
||||||
|
// only need section membership/counts (e.g. ClearCompletedAsync) should use this directly
|
||||||
|
// rather than ClassifyItems, which additionally mutates every row via ApplyChainGrouping.
|
||||||
|
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) PartitionItems()
|
||||||
|
{
|
||||||
// Items is already ordered by SortOrder from the DB query.
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||||
@@ -418,7 +586,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (!emitted.Add(parent.Id)) continue;
|
if (!emitted.Add(parent.Id)) continue;
|
||||||
flat.Add(parent);
|
flat.Add(parent);
|
||||||
// Also expand for Done parents so their (Done) children reach the classification
|
// Also expand for Done parents so their (Done) children reach the classification
|
||||||
// loop and land in CompletedItems alongside the parent.
|
// loop and land alongside the parent in Completed.
|
||||||
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
|
||||||
{
|
{
|
||||||
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
var children = Items.Where(r => r.ParentTaskId == parent.Id);
|
||||||
@@ -445,18 +613,133 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
open.Add(r);
|
open.Add(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
|
return (overdue, open, completed);
|
||||||
// Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
|
}
|
||||||
// keeps every unchanged container instead of tearing the whole list down on a Reset.
|
|
||||||
SyncCollection(OverdueItems, overdue);
|
|
||||||
SyncCollection(OpenItems, open);
|
|
||||||
SyncCollection(CompletedItems, completed);
|
|
||||||
|
|
||||||
HasOverdue = OverdueItems.Count > 0;
|
// ClassifyItems layers the chain-grouping mutation on top of PartitionItems, exactly as
|
||||||
HasOpen = OpenItems.Count > 0;
|
// Regroup renders it — every row in the result has ShowAsChainMember/ChainStep/ChainAfterLabel
|
||||||
HasCompleted = CompletedItems.Count > 0;
|
// written. Only call this from a path that actually renders the result; a caller that just
|
||||||
ShowOpenLabel = HasOpen && HasOverdue;
|
// needs section membership (e.g. a count) should call PartitionItems instead.
|
||||||
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
|
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) ClassifyItems()
|
||||||
|
{
|
||||||
|
var (overdue, open, completed) = PartitionItems();
|
||||||
|
|
||||||
|
// Dependency chains are resolved and pulled together per section (not on the
|
||||||
|
// pre-split `flat` list): a chain head might land in a different section than its
|
||||||
|
// dependent (e.g. a Done head in Completed, an open dependent in Open) — in that case
|
||||||
|
// the two are never rendered adjacent, so the dependent must fall back to a flat row
|
||||||
|
// with a label rather than an orphaned rail. Whether the head is resolvable at all
|
||||||
|
// still uses the whole-Items graph (rowsById), independent of section.
|
||||||
|
var rowsById = Items.ToDictionary(r => r.Id);
|
||||||
|
overdue = ApplyChainGrouping(overdue, rowsById);
|
||||||
|
open = ApplyChainGrouping(open, rowsById);
|
||||||
|
completed = ApplyChainGrouping(completed, rowsById);
|
||||||
|
|
||||||
|
return (overdue, open, completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
|
||||||
|
// terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
|
||||||
|
private const int MaxChainWalkDepth = 64;
|
||||||
|
|
||||||
|
private readonly record struct ChainWalkResult(TaskRowViewModel Head, int Step);
|
||||||
|
|
||||||
|
// Walks DependsOnTaskId back to its root. Returns null when the chain can't be resolved —
|
||||||
|
// either the row has no predecessor at all, or a predecessor along the way isn't loaded
|
||||||
|
// into the current Items (e.g. filtered out of this list/view).
|
||||||
|
private static ChainWalkResult? WalkChainHead(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||||
|
{
|
||||||
|
var current = row;
|
||||||
|
var step = 0;
|
||||||
|
var visited = new HashSet<string> { row.Id };
|
||||||
|
while (!string.IsNullOrEmpty(current.DependsOnTaskId))
|
||||||
|
{
|
||||||
|
if (!rowsById.TryGetValue(current.DependsOnTaskId, out var predecessor)) return null;
|
||||||
|
if (step >= MaxChainWalkDepth || !visited.Add(predecessor.Id)) return null;
|
||||||
|
current = predecessor;
|
||||||
|
step++;
|
||||||
|
}
|
||||||
|
return step == 0 ? null : new ChainWalkResult(current, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
|
||||||
|
// holds just the identifying fragment rather than an assembled sentence.
|
||||||
|
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
|
||||||
|
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
|
||||||
|
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
|
||||||
|
// returns a re-ordered copy with chain dependents pulled directly under their head
|
||||||
|
// (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
|
||||||
|
// planning children already sit right after their parent regardless of Items order.
|
||||||
|
private static List<TaskRowViewModel> ApplyChainGrouping(
|
||||||
|
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
|
||||||
|
{
|
||||||
|
var sectionIds = section.Select(r => r.Id).ToHashSet();
|
||||||
|
var headIdOf = new Dictionary<TaskRowViewModel, string>();
|
||||||
|
var stepOf = new Dictionary<TaskRowViewModel, int>();
|
||||||
|
|
||||||
|
foreach (var r in section)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(r.DependsOnTaskId))
|
||||||
|
{
|
||||||
|
r.ShowAsChainMember = false;
|
||||||
|
r.ChainStep = null;
|
||||||
|
r.ChainAfterLabel = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A planning child keeps its parent indent — chain membership only ever shows as a
|
||||||
|
// label for it, never as a second, nested rail (design: "parent wins").
|
||||||
|
if (r.IsChild)
|
||||||
|
{
|
||||||
|
r.ShowAsChainMember = false;
|
||||||
|
r.ChainStep = null;
|
||||||
|
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk = WalkChainHead(r, rowsById);
|
||||||
|
if (walk is { } w && sectionIds.Contains(w.Head.Id))
|
||||||
|
{
|
||||||
|
r.ShowAsChainMember = true;
|
||||||
|
r.ChainStep = w.Step;
|
||||||
|
r.ChainAfterLabel = null;
|
||||||
|
headIdOf[r] = w.Head.Id;
|
||||||
|
stepOf[r] = w.Step;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
r.ShowAsChainMember = false;
|
||||||
|
r.ChainStep = null;
|
||||||
|
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headIdOf.Count == 0) return section;
|
||||||
|
|
||||||
|
var membersByHeadId = section
|
||||||
|
.Where(headIdOf.ContainsKey)
|
||||||
|
.GroupBy(r => headIdOf[r])
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(r => stepOf[r]).ToList());
|
||||||
|
|
||||||
|
var ordered = new List<TaskRowViewModel>(section.Count);
|
||||||
|
var consumed = new HashSet<string>();
|
||||||
|
foreach (var r in section)
|
||||||
|
{
|
||||||
|
if (consumed.Contains(r.Id)) continue;
|
||||||
|
if (headIdOf.ContainsKey(r)) continue; // placed via its head below, in step order
|
||||||
|
ordered.Add(r);
|
||||||
|
consumed.Add(r.Id);
|
||||||
|
if (membersByHeadId.TryGetValue(r.Id, out var members))
|
||||||
|
foreach (var m in members)
|
||||||
|
if (consumed.Add(m.Id))
|
||||||
|
ordered.Add(m);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSubtitle()
|
private void UpdateSubtitle()
|
||||||
@@ -502,7 +785,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
|
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
|
||||||
Items.Add(row);
|
Items.Add(row);
|
||||||
Regroup();
|
Regroup();
|
||||||
SelectedTask = row;
|
SelectFrom(row, "new-task");
|
||||||
NewTaskTitle = "";
|
NewTaskTitle = "";
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -554,34 +837,41 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
||||||
MoveWithinCollection(Items, source, target, placeBelow);
|
MoveWithinCollection(Items, source, target, placeBelow);
|
||||||
|
|
||||||
// Apply the same move in whichever section the row lives in.
|
// Apply the same move in Rows, but only when both rows live in the same section.
|
||||||
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
|
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
|
||||||
// that's determined by Done flag and ScheduledFor date, not drag-drop.
|
// that's determined by Done flag and ScheduledFor date, not drag-drop.
|
||||||
var sourceSection = SectionFor(source);
|
var sourceSection = SectionFor(source);
|
||||||
var targetSection = SectionFor(target);
|
var targetSection = SectionFor(target);
|
||||||
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
|
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
|
||||||
MoveWithinCollection(sourceSection, source, target, placeBelow);
|
MoveWithinCollection<object>(Rows, source, target, placeBelow);
|
||||||
|
|
||||||
var listId = _currentList.Id["user:".Length..];
|
var listId = _currentList.Id["user:".Length..];
|
||||||
var orderedIds = Items.Select(i => i.Id).ToList();
|
var orderedIds = Items.Select(i => i.Id).ToList();
|
||||||
|
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var idSet = orderedIds.ToHashSet();
|
var ok = false;
|
||||||
var entities = await db.Tasks
|
try
|
||||||
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
|
||||||
.ToListAsync();
|
|
||||||
for (int i = 0; i < orderedIds.Count; i++)
|
|
||||||
{
|
{
|
||||||
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
if (e is not null) e.SortOrder = i;
|
var idSet = orderedIds.ToHashSet();
|
||||||
|
var entities = await db.Tasks
|
||||||
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
for (int i = 0; i < orderedIds.Count; i++)
|
||||||
|
{
|
||||||
|
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
||||||
|
if (e is not null) e.SortOrder = i;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
ok = true;
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReorderAsync", sw.Elapsed, ok); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void MoveWithinCollection(
|
private static void MoveWithinCollection<T>(
|
||||||
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
|
System.Collections.ObjectModel.ObservableCollection<T> coll,
|
||||||
TaskRowViewModel source,
|
T source,
|
||||||
TaskRowViewModel target,
|
T target,
|
||||||
bool placeBelow)
|
bool placeBelow)
|
||||||
{
|
{
|
||||||
var srcIdx = coll.IndexOf(source);
|
var srcIdx = coll.IndexOf(source);
|
||||||
@@ -599,11 +889,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
|
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
|
||||||
// so unchanged rows keep their containers (no Reset-driven full re-render).
|
// so unchanged rows keep their containers (no Reset-driven full re-render).
|
||||||
private static void SyncCollection(
|
private static void SyncCollection<T>(
|
||||||
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
|
System.Collections.ObjectModel.ObservableCollection<T> dst,
|
||||||
List<TaskRowViewModel> target)
|
List<T> target)
|
||||||
{
|
{
|
||||||
var keep = new HashSet<TaskRowViewModel>(target);
|
var keep = new HashSet<T>(target);
|
||||||
for (int i = dst.Count - 1; i >= 0; i--)
|
for (int i = dst.Count - 1; i >= 0; i--)
|
||||||
if (!keep.Contains(dst[i]))
|
if (!keep.Contains(dst[i]))
|
||||||
dst.RemoveAt(i);
|
dst.RemoveAt(i);
|
||||||
@@ -619,12 +909,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
// Marker returned for a row that sits in the flat Open section before any header — Open is the
|
||||||
|
// only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel
|
||||||
|
// false). Distinct from null, which means "row not found in Rows at all".
|
||||||
|
private static readonly object UnlabeledSection = new();
|
||||||
|
|
||||||
|
// A row's section is read off the nearest preceding HeaderRow in Rows rather than
|
||||||
|
// re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered
|
||||||
|
// (including planning-parent edge cases the classification alone can't reconstruct).
|
||||||
|
private object? SectionFor(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
if (OverdueItems.Contains(row)) return OverdueItems;
|
var idx = Rows.IndexOf(row);
|
||||||
if (OpenItems.Contains(row)) return OpenItems;
|
if (idx < 0) return null;
|
||||||
if (CompletedItems.Contains(row)) return CompletedItems;
|
for (int i = idx - 1; i >= 0; i--)
|
||||||
return null;
|
if (Rows[i] is HeaderRow header) return header;
|
||||||
|
return UnlabeledSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -717,11 +1016,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ClearCompletedAsync()
|
private async Task ClearCompletedAsync()
|
||||||
{
|
{
|
||||||
if (CompletedItems.Count == 0) return;
|
var (_, _, completed) = PartitionItems();
|
||||||
|
if (completed.Count == 0) return;
|
||||||
|
|
||||||
// Delete children before parents so the parent-child FK (Restrict) doesn't
|
// Delete children before parents so the parent-child FK (Restrict) doesn't
|
||||||
// block removing a completed planning parent together with its done children.
|
// block removing a completed planning parent together with its done children.
|
||||||
var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList();
|
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
|
||||||
|
|
||||||
if (ConfirmAsync is not null)
|
if (ConfirmAsync is not null)
|
||||||
{
|
{
|
||||||
@@ -729,17 +1029,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var repo = new TaskRepository(db);
|
try
|
||||||
foreach (var row in toDelete)
|
|
||||||
{
|
{
|
||||||
try
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var repo = new TaskRepository(db);
|
||||||
|
foreach (var row in toDelete)
|
||||||
{
|
{
|
||||||
await repo.DeleteAsync(row.Id);
|
try
|
||||||
Items.Remove(row);
|
{
|
||||||
|
await repo.DeleteAsync(row.Id);
|
||||||
|
Items.Remove(row);
|
||||||
|
}
|
||||||
|
catch { /* still referenced by open child tasks; leave it visible */ }
|
||||||
}
|
}
|
||||||
catch { /* still referenced by open child tasks; leave it visible */ }
|
|
||||||
}
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); }
|
||||||
|
|
||||||
Regroup();
|
Regroup();
|
||||||
UpdateSubtitle();
|
UpdateSubtitle();
|
||||||
@@ -826,10 +1131,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||||
{
|
{
|
||||||
if (_worker is null) return;
|
if (_worker is null) return;
|
||||||
try { await _worker.SetTaskStatusAsync(row.Id, status); }
|
try
|
||||||
|
{
|
||||||
|
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
|
||||||
|
ReportBaseDirty(baseDirty);
|
||||||
|
}
|
||||||
catch { /* offline; broadcast won't fire */ }
|
catch { /* offline; broadcast won't fire */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
|
||||||
|
{
|
||||||
|
if (warning is null) return;
|
||||||
|
ErrorReported?.Invoke(Loc.T(
|
||||||
|
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
@@ -856,7 +1172,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
|
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
|
||||||
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
|
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
|
||||||
// TaskUpdated broadcast.
|
// TaskUpdated broadcast.
|
||||||
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
|
try
|
||||||
|
{
|
||||||
|
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
|
||||||
|
ReportBaseDirty(baseDirty);
|
||||||
|
}
|
||||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,7 +1285,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");
|
||||||
|
|
||||||
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
|
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
|
||||||
{
|
{
|
||||||
@@ -975,13 +1295,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||||
if (row is null) return false;
|
if (row is null) return false;
|
||||||
SelectedTask = row;
|
SelectFrom(row, "select-by-id");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
||||||
|
|
||||||
|
// The completed section is only emitted into Rows while the toggle is on — flipping it
|
||||||
|
// doesn't change grouping/order data, so a full reload isn't needed, just a re-emit.
|
||||||
|
partial void OnIsShowingCompletedChanged(bool value) => Regroup();
|
||||||
|
|
||||||
public event EventHandler? OpenListSettingsRequested;
|
public event EventHandler? OpenListSettingsRequested;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1041,7 +1365,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
|
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||||
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
await FinalizePlanningSessionAsync(row);
|
||||||
break;
|
break;
|
||||||
case UnfinishedPlanningModalResult.Discard:
|
case UnfinishedPlanningModalResult.Discard:
|
||||||
await TryDiscardPlanningWithRetryAsync(row.Id);
|
await TryDiscardPlanningWithRetryAsync(row.Id);
|
||||||
@@ -1097,18 +1421,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||||
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || _worker is null) return;
|
if (row is null || _worker is null) return;
|
||||||
|
using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks"));
|
||||||
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||||
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
|
using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing"));
|
||||||
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
@@ -1149,4 +1477,106 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||||
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
SelectionChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: reconcile tick ──────────────────────────────────────────────
|
||||||
|
// Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items`
|
||||||
|
// master collection against SQLite and patch properties in place. Never rebuilds a row and
|
||||||
|
// never falls back to LoadForList — the delta path already owns that escalation. It DOES
|
||||||
|
// call Regroup(), but only when a patch actually moved a row's grouping inputs: a healed
|
||||||
|
// row can change section, order or rail label, and none of that shows until Rows is
|
||||||
|
// re-emitted.
|
||||||
|
|
||||||
|
// Above this many rows, only the first N (in Items order) are reconciled per tick, so the
|
||||||
|
// query cost stays bounded instead of growing with an ever-larger list.
|
||||||
|
internal const int ReconcileRowCap = 500;
|
||||||
|
|
||||||
|
// Set by the shell from the main window's WindowState; the tick no-ops while minimized.
|
||||||
|
public bool IsWindowVisible { get; set; } = true;
|
||||||
|
|
||||||
|
// Test seam: awaited right after the reconcile query returns and before its results are
|
||||||
|
// applied, so a test can land a fresher, higher-sequence update in that gap and exercise the
|
||||||
|
// stale-sequence guard deterministically. Always null outside tests.
|
||||||
|
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
|
||||||
|
|
||||||
|
// Awaitable so tests can drive it deterministically (mirrors RefreshTaskFromWorkerAsync).
|
||||||
|
internal async Task ReconcileTickAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var list = _currentList;
|
||||||
|
if (!IsWindowVisible || list is null) return;
|
||||||
|
|
||||||
|
// Mirrors the full-reload guard in RefreshTaskFromWorkerAsync: matching virtual:queued /
|
||||||
|
// virtual:running depends on a Planning parent's children, not a single entity.
|
||||||
|
if (list.Kind == ListKind.Virtual &&
|
||||||
|
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ids = Items.Select(r => r.Id).Take(ReconcileRowCap).ToList();
|
||||||
|
if (ids.Count == 0) return;
|
||||||
|
|
||||||
|
// Same monotonic per-task sequence as the Phase 1 delta path, so whichever of the two
|
||||||
|
// started most recently for a given task id wins, regardless of completion order.
|
||||||
|
var seqByTaskId = new Dictionary<string, long>(ids.Count);
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var seq = ++_deltaCounter;
|
||||||
|
seqByTaskId[id] = seq;
|
||||||
|
_deltaSeq[id] = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TaskEntity> entities;
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var dbOk = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var idSet = ids.ToHashSet();
|
||||||
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
entities = await db.Tasks
|
||||||
|
.Include(t => t.List)
|
||||||
|
.Include(t => t.Worktree)
|
||||||
|
.Where(t => idSet.Contains(t.Id))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
dbOk = true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }
|
||||||
|
|
||||||
|
if (ReconcileTickTestBarrier is { } barrier) await barrier();
|
||||||
|
|
||||||
|
var byId = entities.ToDictionary(e => e.Id);
|
||||||
|
// Index the rows once instead of scanning Items per id — at the 500-row cap a linear
|
||||||
|
// scan per id is 250k comparisons every few seconds, for nothing.
|
||||||
|
var rowById = new Dictionary<string, TaskRowViewModel>(Items.Count);
|
||||||
|
foreach (var r in Items) rowById[r.Id] = r;
|
||||||
|
var groupingChanged = false;
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
// Superseded by a fresher delta refresh or a later tick that landed while this one
|
||||||
|
// was reading — its result is stale, discard it.
|
||||||
|
if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue;
|
||||||
|
if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick
|
||||||
|
|
||||||
|
if (!rowById.TryGetValue(id, out var row)) continue;
|
||||||
|
var before = GroupingKey(row);
|
||||||
|
row.UpdateFromEntity(entity);
|
||||||
|
if (!before.Equals(GroupingKey(row))) groupingChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A patched row can have left its section (Done), moved inside it (a new/removed
|
||||||
|
// depends-on link re-orders the chain), or changed what its rail label reads. Rows only
|
||||||
|
// reflects any of that after a Regroup — without this the tick would heal the row's data
|
||||||
|
// while leaving a completed task sitting in the Open section under a stale count. Gated
|
||||||
|
// on a real change so an idle tick stays free.
|
||||||
|
if (groupingChanged) Regroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
|
||||||
|
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
|
||||||
|
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
|
||||||
|
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
|
||||||
|
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
|
||||||
|
|
||||||
|
// Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open
|
||||||
|
// on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while
|
||||||
|
// the worker is down, and that's exactly when the dialog's "Start Worker" is needed.
|
||||||
|
public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true);
|
||||||
|
|
||||||
|
internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected;
|
||||||
|
|
||||||
private readonly UpdateCheckService _updateCheck = null!;
|
private readonly UpdateCheckService _updateCheck = null!;
|
||||||
private readonly InstallerLocator _installerLocator = null!;
|
private readonly InstallerLocator _installerLocator = null!;
|
||||||
private readonly WorkerLocator _workerLocator = null!;
|
private readonly WorkerLocator _workerLocator = null!;
|
||||||
@@ -296,7 +303,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
_git = git;
|
_git = git;
|
||||||
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
|
||||||
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
|
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
|
||||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
|
||||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||||
Tasks.ErrorReported += FlashFooterError;
|
Tasks.ErrorReported += FlashFooterError;
|
||||||
@@ -333,7 +340,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
Lists.LetClaudeHandleListCommand.Execute(row);
|
Lists.LetClaudeHandleListCommand.Execute(row);
|
||||||
};
|
};
|
||||||
Details.ErrorReported += FlashFooterError;
|
Details.ErrorReported += FlashFooterError;
|
||||||
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
|
||||||
Details.DeleteFromList = row =>
|
Details.DeleteFromList = row =>
|
||||||
{
|
{
|
||||||
Tasks.LoadForList(Lists.SelectedList);
|
Tasks.LoadForList(Lists.SelectedList);
|
||||||
@@ -346,6 +353,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ConnectionText));
|
OnPropertyChanged(nameof(ConnectionText));
|
||||||
OnPropertyChanged(nameof(IsOffline));
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
|
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
|
||||||
@@ -377,11 +385,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
RefreshBannerFromStatus();
|
RefreshBannerFromStatus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Fire-and-forget startup check — never block UI.
|
_updateCheck.Op.PropertyChanged += (_, e) =>
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
|
||||||
});
|
};
|
||||||
|
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
|
||||||
|
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
|
||||||
|
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
|
||||||
|
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
|
||||||
|
_ = StartupUpdateCheckAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartupUpdateCheckAsync()
|
||||||
|
{
|
||||||
|
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -513,8 +530,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
|
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
|
||||||
|
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
|
||||||
|
// the opposite of what it says.
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
|
private Task OpenWorkerConnectionHelp() =>
|
||||||
|
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenRepoImport()
|
private async Task OpenRepoImport()
|
||||||
@@ -570,13 +591,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
var vm = _usageMonitorVmFactory();
|
var vm = _usageMonitorVmFactory();
|
||||||
vm.ErrorReported += FlashFooterError;
|
vm.ErrorReported += FlashFooterError;
|
||||||
await vm.LoadAsync();
|
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
|
||||||
|
// here left the pill looking unresponsive until the window finally appeared.
|
||||||
|
vm.BeginLoad();
|
||||||
await Dialogs.ShowUsageMonitorAsync(vm);
|
await Dialogs.ShowUsageMonitorAsync(vm);
|
||||||
}
|
}
|
||||||
finally { _usageMonitorOpen = false; }
|
finally { _usageMonitorOpen = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||||
private readonly Action<string> _onTaskUpdated;
|
private readonly Action<string> _onTaskUpdated;
|
||||||
private readonly Action _onConnectionRestored;
|
private readonly Action _onConnectionRestored;
|
||||||
private readonly Action<string, IReadOnlyList<string>> _onHandoffRequested;
|
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
||||||
|
|
||||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||||
// review/merge/status machinery.
|
// review/merge/status machinery.
|
||||||
@@ -85,7 +85,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
||||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||||
|
|
||||||
_onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); };
|
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||||
|
|
||||||
_ = RefreshQueueAsync();
|
_ = RefreshQueueAsync();
|
||||||
@@ -131,7 +131,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(taskId)) return;
|
if (string.IsNullOrEmpty(taskId)) return;
|
||||||
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
|
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
|
||||||
try { await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued); }
|
try
|
||||||
|
{
|
||||||
|
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
|
||||||
|
if (baseDirty is not null)
|
||||||
|
ErrorReported?.Invoke(Loc.T(
|
||||||
|
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
|
||||||
|
}
|
||||||
catch { /* best-effort enqueue */ }
|
catch { /* best-effort enqueue */ }
|
||||||
await RefreshQueueAsync();
|
await RefreshQueueAsync();
|
||||||
}
|
}
|
||||||
@@ -147,6 +153,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(taskId)) return;
|
if (string.IsNullOrEmpty(taskId)) return;
|
||||||
|
// One pane per TaskId is an invariant -- a merge-helper handoff closes its outgoing pane
|
||||||
|
// before opening the next phase's (see OpenMergeHelperHandoffConPtySessionAsync).
|
||||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||||
{
|
{
|
||||||
FocusedPane = existing;
|
FocusedPane = existing;
|
||||||
@@ -275,13 +283,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List-handler handoff: the running session called handoff_list_handler at the end of Phase 2.
|
// List-handler handoff: the running session called handoff_list_handler at the end of a phase.
|
||||||
// Replaces the Phase 1-2 tile with a fresh one for the SAME handler task id, which carries out
|
// Opens a NEW tile for the SAME handler task id to carry out nextPhase. Each tile is a live
|
||||||
// Phases 3-5. The old tile used to be left open so its last message could still be read, but
|
// `claude` process with the full mcp__claudedo__* surface, so the outgoing phase's pane is
|
||||||
// its process is gone by then and the terminal renders empty — it was only ever a dead
|
// closed first -- it was only ever told to end its turn, never to exit, and a multi-phase run
|
||||||
// placeholder to close by hand. No new task is created here; see
|
// would otherwise leave one live process per phase behind. This keeps the one-pane-per-TaskId
|
||||||
|
// invariant intact, so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can keep using
|
||||||
|
// FirstOrDefault. No new task is created here; see
|
||||||
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
|
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
|
||||||
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList<string> survivingTaskIds)
|
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(
|
||||||
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
||||||
|
|
||||||
@@ -304,7 +315,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* best-effort title lookup */ }
|
catch { /* best-effort title lookup */ }
|
||||||
|
|
||||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds))));
|
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
||||||
@@ -334,6 +345,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
// SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
|
// SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
|
||||||
private async void OnPaneSubmitForReview(string taskId)
|
private async void OnPaneSubmitForReview(string taskId)
|
||||||
{
|
{
|
||||||
|
// See OpenConPtySessionAsync -- one pane per TaskId is an invariant, so FirstOrDefault is
|
||||||
|
// always the current/active pane for this task.
|
||||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
/// Which role a row plays on one side of the split view.
|
||||||
|
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
|
||||||
|
|
||||||
|
/// A character range inside a row's text, used for intra-line (word) highlighting.
|
||||||
|
public readonly record struct TextSpan(int Start, int Length);
|
||||||
|
|
||||||
|
/// One rendered row of the side-by-side view. Left is the old state, right the new.
|
||||||
|
public sealed record SplitRow(
|
||||||
|
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
|
||||||
|
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
|
||||||
|
|
||||||
|
/// One rendered row of the single-pane view.
|
||||||
|
public sealed record UnifiedRow(
|
||||||
|
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
|
||||||
|
|
||||||
|
/// Render-ready diff. Row index <c>i</c> is document line <c>i + 1</c> in the matching text —
|
||||||
|
/// that mapping is what the line-number margin and both background renderers rely on.
|
||||||
|
public sealed record AlignedDiff(
|
||||||
|
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
|
||||||
|
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText)
|
||||||
|
{
|
||||||
|
public static readonly AlignedDiff Empty = new(
|
||||||
|
Array.Empty<SplitRow>(), "", "", Array.Empty<UnifiedRow>(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a parsed unified-diff line stream into aligned rows. Pure — no Avalonia types,
|
||||||
|
/// so all of the interesting behaviour is unit-testable.
|
||||||
|
public static class DiffAlignment
|
||||||
|
{
|
||||||
|
/// Marker text for a skipped region between two hunks.
|
||||||
|
public const string GapText = "⋯";
|
||||||
|
|
||||||
|
/// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale,
|
||||||
|
/// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs.
|
||||||
|
public const int MaxWordDiffChars = 2000;
|
||||||
|
public const int MaxWordDiffTokens = 400;
|
||||||
|
public const double MinWordDiffSimilarity = 0.5;
|
||||||
|
|
||||||
|
internal static readonly IReadOnlyList<TextSpan> NoSpans = Array.Empty<TextSpan>();
|
||||||
|
|
||||||
|
public static AlignedDiff Build(IReadOnlyList<DiffLineViewModel>? lines)
|
||||||
|
{
|
||||||
|
if (lines is null || lines.Count == 0) return AlignedDiff.Empty;
|
||||||
|
|
||||||
|
var split = new List<SplitRow>();
|
||||||
|
var unified = new List<UnifiedRow>();
|
||||||
|
int? prevOld = null, prevNew = null;
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.Count)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
|
||||||
|
// File-header rows only appear in the legacy flattened stream; alignment is per file.
|
||||||
|
if (line.Kind == DiffLineKind.File) { i++; continue; }
|
||||||
|
|
||||||
|
if (IsGap(prevOld, prevNew, line))
|
||||||
|
{
|
||||||
|
split.Add(new SplitRow(AlignedSide.Gap, null, GapText, NoSpans,
|
||||||
|
AlignedSide.Gap, null, GapText, NoSpans));
|
||||||
|
unified.Add(new UnifiedRow(AlignedSide.Gap, null, null, GapText, NoSpans));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Kind == DiffLineKind.Ctx)
|
||||||
|
{
|
||||||
|
split.Add(new SplitRow(AlignedSide.Ctx, line.OldNo, line.Text, NoSpans,
|
||||||
|
AlignedSide.Ctx, line.NewNo, line.Text, NoSpans));
|
||||||
|
unified.Add(new UnifiedRow(AlignedSide.Ctx, line.OldNo, line.NewNo, line.Text, NoSpans));
|
||||||
|
prevOld = line.OldNo;
|
||||||
|
prevNew = line.NewNo;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change block: the parser always emits the deletions before the additions.
|
||||||
|
var dels = new List<DiffLineViewModel>();
|
||||||
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]);
|
||||||
|
var adds = new List<DiffLineViewModel>();
|
||||||
|
while (i < lines.Count && lines[i].Kind == DiffLineKind.Add) adds.Add(lines[i++]);
|
||||||
|
|
||||||
|
EmitChangeBlock(dels, adds, split, unified);
|
||||||
|
|
||||||
|
if (dels.Count > 0) prevOld = dels[^1].OldNo;
|
||||||
|
if (adds.Count > 0) prevNew = adds[^1].NewNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AlignedDiff(
|
||||||
|
split,
|
||||||
|
string.Join('\n', split.Select(r => r.LeftText)),
|
||||||
|
string.Join('\n', split.Select(r => r.RightText)),
|
||||||
|
unified,
|
||||||
|
string.Join('\n', unified.Select(r => r.Text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parser drops "@@" headers, so a skipped region is visible only as a jump in the
|
||||||
|
/// line numbers. Nothing precedes the first row, so it can never open with a gap.
|
||||||
|
private static bool IsGap(int? prevOld, int? prevNew, DiffLineViewModel next)
|
||||||
|
{
|
||||||
|
if (prevOld is { } po && next.OldNo is { } no && no > po + 1) return true;
|
||||||
|
if (prevNew is { } pn && next.NewNo is { } nn && nn > pn + 1) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EmitChangeBlock(
|
||||||
|
List<DiffLineViewModel> dels, List<DiffLineViewModel> adds,
|
||||||
|
List<SplitRow> split, List<UnifiedRow> unified)
|
||||||
|
{
|
||||||
|
var paired = Math.Min(dels.Count, adds.Count);
|
||||||
|
|
||||||
|
// Word diff is only meaningful for rows that stand 1:1 opposite each other.
|
||||||
|
var spans = new (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right)[paired];
|
||||||
|
for (var k = 0; k < paired; k++)
|
||||||
|
spans[k] = WordDiff(dels[k].Text, adds[k].Text);
|
||||||
|
|
||||||
|
for (var k = 0; k < paired; k++)
|
||||||
|
split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, spans[k].Left,
|
||||||
|
AlignedSide.Add, adds[k].NewNo, adds[k].Text, spans[k].Right));
|
||||||
|
for (var k = paired; k < dels.Count; k++)
|
||||||
|
split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, NoSpans,
|
||||||
|
AlignedSide.Filler, null, "", NoSpans));
|
||||||
|
for (var k = paired; k < adds.Count; k++)
|
||||||
|
split.Add(new SplitRow(AlignedSide.Filler, null, "", NoSpans,
|
||||||
|
AlignedSide.Add, adds[k].NewNo, adds[k].Text, NoSpans));
|
||||||
|
|
||||||
|
for (var k = 0; k < dels.Count; k++)
|
||||||
|
unified.Add(new UnifiedRow(AlignedSide.Del, dels[k].OldNo, null, dels[k].Text,
|
||||||
|
k < paired ? spans[k].Left : NoSpans));
|
||||||
|
for (var k = 0; k < adds.Count; k++)
|
||||||
|
unified.Add(new UnifiedRow(AlignedSide.Add, null, adds[k].NewNo, adds[k].Text,
|
||||||
|
k < paired ? spans[k].Right : NoSpans));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair
|
||||||
|
/// is too long, too token-heavy or too dissimilar for per-word highlighting to help.
|
||||||
|
internal static (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
|
||||||
|
string left, string right)
|
||||||
|
{
|
||||||
|
if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal))
|
||||||
|
return (NoSpans, NoSpans);
|
||||||
|
if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars)
|
||||||
|
return (NoSpans, NoSpans);
|
||||||
|
|
||||||
|
var a = Tokenize(left);
|
||||||
|
var b = Tokenize(right);
|
||||||
|
if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens)
|
||||||
|
return (NoSpans, NoSpans);
|
||||||
|
|
||||||
|
var (keepA, keepB, common) = LongestCommonSubsequence(a, b);
|
||||||
|
var similarity = common / (double)Math.Max(a.Count, b.Count);
|
||||||
|
if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans);
|
||||||
|
|
||||||
|
return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a line into runs of word characters, runs of whitespace, and single
|
||||||
|
/// punctuation characters — the granularity that makes an identifier rename read as
|
||||||
|
/// one changed token rather than a string of changed characters.
|
||||||
|
private static List<Token> Tokenize(string s)
|
||||||
|
{
|
||||||
|
var tokens = new List<Token>();
|
||||||
|
var i = 0;
|
||||||
|
while (i < s.Length)
|
||||||
|
{
|
||||||
|
var start = i;
|
||||||
|
if (IsWordChar(s[i]))
|
||||||
|
while (i < s.Length && IsWordChar(s[i])) i++;
|
||||||
|
else if (char.IsWhiteSpace(s[i]))
|
||||||
|
while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
|
||||||
|
else
|
||||||
|
i++;
|
||||||
|
tokens.Add(new Token(start, s[start..i]));
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_';
|
||||||
|
|
||||||
|
private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence(
|
||||||
|
List<Token> a, List<Token> b)
|
||||||
|
{
|
||||||
|
var n = a.Count;
|
||||||
|
var m = b.Count;
|
||||||
|
var dp = new int[n + 1, m + 1];
|
||||||
|
for (var i = n - 1; i >= 0; i--)
|
||||||
|
for (var j = m - 1; j >= 0; j--)
|
||||||
|
dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal)
|
||||||
|
? dp[i + 1, j + 1] + 1
|
||||||
|
: Math.Max(dp[i + 1, j], dp[i, j + 1]);
|
||||||
|
|
||||||
|
var keepA = new bool[n];
|
||||||
|
var keepB = new bool[m];
|
||||||
|
int x = 0, y = 0;
|
||||||
|
while (x < n && y < m)
|
||||||
|
{
|
||||||
|
if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
keepA[x] = keepB[y] = true;
|
||||||
|
x++; y++;
|
||||||
|
}
|
||||||
|
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
|
||||||
|
else y++;
|
||||||
|
}
|
||||||
|
return (keepA, keepB, dp[0, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges runs of consecutive unmatched tokens so "zz" is one span, not two.
|
||||||
|
private static IReadOnlyList<TextSpan> SpansForUnmatched(List<Token> tokens, bool[] keep)
|
||||||
|
{
|
||||||
|
var spans = new List<TextSpan>();
|
||||||
|
var i = 0;
|
||||||
|
while (i < tokens.Count)
|
||||||
|
{
|
||||||
|
if (keep[i]) { i++; continue; }
|
||||||
|
var start = tokens[i].Start;
|
||||||
|
var end = start + tokens[i].Text.Length;
|
||||||
|
i++;
|
||||||
|
while (i < tokens.Count && !keep[i])
|
||||||
|
{
|
||||||
|
end = tokens[i].Start + tokens[i].Text.Length;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
spans.Add(new TextSpan(start, end - start));
|
||||||
|
}
|
||||||
|
return spans.Count == 0 ? NoSpans : spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tokenized slice of a line, carrying its offset so spans map back to characters.
|
||||||
|
private readonly record struct Token(int Start, string Text);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel.
|
// Shared diff models used by UnifiedDiffParser, DiffTextView and DiffViewerViewModel.
|
||||||
|
|
||||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||||
|
|
||||||
@@ -15,20 +15,6 @@ public sealed class DiffLineViewModel
|
|||||||
public int? OldNo { get; init; }
|
public int? OldNo { get; init; }
|
||||||
public int? NewNo { get; init; }
|
public int? NewNo { get; init; }
|
||||||
public required string Text { get; init; }
|
public required string Text { get; init; }
|
||||||
public string ClassName => Kind switch
|
|
||||||
{
|
|
||||||
DiffLineKind.Add => "add",
|
|
||||||
DiffLineKind.Del => "del",
|
|
||||||
DiffLineKind.File => "file",
|
|
||||||
_ => "ctx",
|
|
||||||
};
|
|
||||||
|
|
||||||
public string Sign => Kind switch
|
|
||||||
{
|
|
||||||
DiffLineKind.Add => "+",
|
|
||||||
DiffLineKind.Del => "-",
|
|
||||||
_ => " ",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DiffFileViewModel
|
public sealed class DiffFileViewModel
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using ClaudeDo.Data.Git;
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Ui;
|
||||||
using ClaudeDo.Ui.Localization;
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsPlanning))]
|
[NotifyPropertyChangedFor(nameof(IsPlanning))]
|
||||||
@@ -52,10 +56,41 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
|
|
||||||
// ── Right pane ──────────────────────────────────────────────────────────
|
// ── Right pane ──────────────────────────────────────────────────────────
|
||||||
[ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode
|
[ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode
|
||||||
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new(); // Planning mode
|
// Planning mode: one entry per file so each gets its own editor and grammar.
|
||||||
|
public ObservableCollection<DiffFileViewModel> PlanningFiles { get; } = new();
|
||||||
[ObservableProperty] private string _displayedDiff = "";
|
[ObservableProperty] private string _displayedDiff = "";
|
||||||
[ObservableProperty] private string? _statusMessage;
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
|
||||||
|
// ── View toggles (persisted to ui.config.json) ──────────────────────────
|
||||||
|
[ObservableProperty] private bool _isSplitView;
|
||||||
|
[ObservableProperty] private bool _wrapLines;
|
||||||
|
|
||||||
|
// The layout picker is a segmented switch, so each segment sets its own mode rather than
|
||||||
|
// flipping a shared flag — clicking the already-active segment must be a no-op.
|
||||||
|
[RelayCommand] private void ShowUnified() => IsSplitView = false;
|
||||||
|
[RelayCommand] private void ShowSplit() => IsSplitView = true;
|
||||||
|
|
||||||
|
partial void OnIsSplitViewChanged(bool value)
|
||||||
|
{
|
||||||
|
_settings.DiffViewMode = value ? "split" : "unified";
|
||||||
|
PersistViewPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnWrapLinesChanged(bool value)
|
||||||
|
{
|
||||||
|
_settings.DiffWrapLines = value;
|
||||||
|
PersistViewPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed preference write must never take the diff viewer down with it; the toggle
|
||||||
|
/// still works for this session, it just won't survive a restart.
|
||||||
|
private void PersistViewPreferences()
|
||||||
|
{
|
||||||
|
try { _settings.Save(); }
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Planning combined toggle ────────────────────────────────────────────
|
// ── Planning combined toggle ────────────────────────────────────────────
|
||||||
[ObservableProperty] private bool _isCombinedMode;
|
[ObservableProperty] private bool _isCombinedMode;
|
||||||
[ObservableProperty] private string? _combinedWarning;
|
[ObservableProperty] private string? _combinedWarning;
|
||||||
@@ -63,10 +98,21 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
public DiffViewerViewModel(GitService git, IWorkerClient worker)
|
// Parsing a large diff (UnifiedDiffParser.Parse + DiffTree.Build, both pure CPU work) is
|
||||||
|
// offloaded to a threadpool thread so it never blocks the UI dispatcher.
|
||||||
|
public OperationStatus ParseOp { get; } = new();
|
||||||
|
|
||||||
|
// Guards the fire-and-forget planning parse below against a stale write: a second
|
||||||
|
// DisplayedDiff change while the first parse is still running must not clobber PlanningFiles.
|
||||||
|
private int _planningFilesGeneration;
|
||||||
|
|
||||||
|
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
|
||||||
{
|
{
|
||||||
_git = git;
|
_git = git;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
|
_settings = settings;
|
||||||
|
_isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase);
|
||||||
|
_wrapLines = settings.DiffWrapLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -107,6 +153,16 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
public Task LoadAsync(CancellationToken ct = default) =>
|
public Task LoadAsync(CancellationToken ct = default) =>
|
||||||
Mode == DiffViewerMode.Planning ? LoadPlanningAsync() : LoadFilesAsync(ct);
|
Mode == DiffViewerMode.Planning ? LoadPlanningAsync() : LoadFilesAsync(ct);
|
||||||
|
|
||||||
|
// Shared by both parse call sites (LoadFilesAsync, ApplyParsedPlanningFilesAsync) so the
|
||||||
|
// ParseOp begin/offload/end ceremony isn't duplicated. Begin runs synchronously on the UI
|
||||||
|
// thread (the caller hasn't awaited anything yet), and the `using` guarantees End runs when
|
||||||
|
// the offloaded parse finishes — even if the caller's own result turns out to be stale.
|
||||||
|
private async Task<T> ParseOffUiThreadAsync<T>(Func<T> parse, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using (ParseOp.Begin(Loc.T("ops.diff.parsing")))
|
||||||
|
return await Task.Run(parse, ct);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadFilesAsync(CancellationToken ct)
|
private async Task LoadFilesAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
FileTree.Clear();
|
FileTree.Clear();
|
||||||
@@ -124,10 +180,10 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct: ct)
|
||||||
: BaseRef is not null
|
: BaseRef is not null
|
||||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct: ct)
|
||||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
: await _git.GetDiffAsync(WorktreePath, ct: ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -141,8 +197,13 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var files = UnifiedDiffParser.Parse(raw).ToList();
|
var (files, tree) = await ParseOffUiThreadAsync(() =>
|
||||||
foreach (var node in DiffTree.Build(files))
|
{
|
||||||
|
var parsed = UnifiedDiffParser.Parse(raw);
|
||||||
|
return (parsed, DiffTree.Build(parsed));
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
foreach (var node in tree)
|
||||||
FileTree.Add(node);
|
FileTree.Add(node);
|
||||||
|
|
||||||
SelectedNode = DiffTree.FirstLeaf(FileTree);
|
SelectedNode = DiffTree.FirstLeaf(FileTree);
|
||||||
@@ -216,11 +277,25 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
|||||||
|
|
||||||
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
|
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
|
||||||
|
|
||||||
|
// DisplayedDiff is set from four places (OnSelectedSubtaskChanged plus three branches of
|
||||||
|
// ToggleCombinedAsync), and OnSelectedSubtaskChanged is itself a synchronous, non-awaitable
|
||||||
|
// callback — funneling the offload through this single change handler covers all of them
|
||||||
|
// without touching every call site. Fire-and-forget is safe because the generation counter
|
||||||
|
// below discards a parse whose result is no longer the current DisplayedDiff.
|
||||||
partial void OnDisplayedDiffChanged(string value)
|
partial void OnDisplayedDiffChanged(string value)
|
||||||
{
|
{
|
||||||
DiffLines.Clear();
|
var generation = ++_planningFilesGeneration;
|
||||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value)))
|
_ = ApplyParsedPlanningFilesAsync(value, generation);
|
||||||
DiffLines.Add(line);
|
}
|
||||||
|
|
||||||
|
private async Task ApplyParsedPlanningFilesAsync(string diff, int generation)
|
||||||
|
{
|
||||||
|
var files = await ParseOffUiThreadAsync(() => UnifiedDiffParser.Parse(diff));
|
||||||
|
if (generation != _planningFilesGeneration) return;
|
||||||
|
|
||||||
|
PlanningFiles.Clear();
|
||||||
|
foreach (var file in files)
|
||||||
|
PlanningFiles.Add(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Merge (Files mode, branch source) ───────────────────────────────────
|
// ── Merge (Files mode, branch source) ───────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
|
// When true the project's .claudedo/ findings store is committed with the repo; when false it
|
||||||
|
// is kept out of git via .git/info/exclude (see FindingsStore / ListEntity.FindingsTracked).
|
||||||
|
[ObservableProperty] private bool _findingsTracked;
|
||||||
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
|
// 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.
|
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
|
||||||
[ObservableProperty] private string _verifyCommand = "";
|
[ObservableProperty] private string _verifyCommand = "";
|
||||||
@@ -55,11 +58,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
string? workingDir,
|
string? workingDir,
|
||||||
string defaultCommitType,
|
string defaultCommitType,
|
||||||
bool isManual = false,
|
bool isManual = false,
|
||||||
|
bool findingsTracked = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ListId = listId;
|
ListId = listId;
|
||||||
Name = name;
|
Name = name;
|
||||||
IsManual = isManual;
|
IsManual = isManual;
|
||||||
|
FindingsTracked = findingsTracked;
|
||||||
WorkingDir = workingDir ?? "";
|
WorkingDir = workingDir ?? "";
|
||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
@@ -76,7 +81,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||||
DefaultCommitType,
|
DefaultCommitType,
|
||||||
IsManual));
|
IsManual,
|
||||||
|
FindingsTracked));
|
||||||
|
|
||||||
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
|
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,21 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
|
|||||||
_worker = worker;
|
_worker = worker;
|
||||||
_copyToClipboard = copyToClipboard;
|
_copyToClipboard = copyToClipboard;
|
||||||
Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged();
|
Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged();
|
||||||
|
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads
|
||||||
|
// through the log), so refresh it on the same cadence as TasksIslandViewModel's tick
|
||||||
|
// instead of leaving it frozen at the moment it was opened.
|
||||||
|
_reconcileTimer.Elapsed += (_, _) =>
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = RefreshAsync());
|
||||||
|
_reconcileTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task RefreshAsync()
|
public async Task RefreshAsync()
|
||||||
{
|
{
|
||||||
_all = await _worker.GetRecentLogsAsync();
|
var latest = await _worker.GetRecentLogsAsync();
|
||||||
Apply();
|
Reconcile(latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnWarnErrorOnlyChanged(bool value) => Apply();
|
partial void OnWarnErrorOnlyChanged(bool value) => Apply();
|
||||||
@@ -60,12 +68,86 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
|
|||||||
: _all;
|
: _all;
|
||||||
foreach (var e in items.Reverse())
|
foreach (var e in items.Reverse())
|
||||||
Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
|
Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
|
||||||
|
UpdateStatusText();
|
||||||
|
}
|
||||||
|
|
||||||
|
// `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest
|
||||||
|
// entries falling off the front once they age past the 30-min window. A full Rows.Clear()
|
||||||
|
// on every 4s tick would reset the user's scroll position/selection while they're mid-read,
|
||||||
|
// so patch just the delta instead: entries still present keep their row, aged-out entries
|
||||||
|
// are trimmed off the (oldest) tail of Rows, new entries are inserted at the (newest) head.
|
||||||
|
private void Reconcile(IReadOnlyList<WorkerLogEntry> latest)
|
||||||
|
{
|
||||||
|
var previous = _all;
|
||||||
|
_all = latest;
|
||||||
|
|
||||||
|
if (previous.Count == 0)
|
||||||
|
{
|
||||||
|
Apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overlap = FindOverlap(previous, latest);
|
||||||
|
var evictedCount = previous.Count - overlap;
|
||||||
|
var added = latest.Skip(overlap).ToList();
|
||||||
|
if (evictedCount == 0 && added.Count == 0)
|
||||||
|
return; // nothing aged out, nothing new — leave Rows (and scroll/selection) untouched
|
||||||
|
|
||||||
|
if (evictedCount > 0)
|
||||||
|
{
|
||||||
|
var removeCount = WarnErrorOnly
|
||||||
|
? previous.Take(evictedCount).Count(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error)
|
||||||
|
: evictedCount;
|
||||||
|
for (var i = 0; i < removeCount; i++)
|
||||||
|
Rows.RemoveAt(Rows.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var e in added)
|
||||||
|
{
|
||||||
|
if (WarnErrorOnly && e.Level is not (WorkerLogLevel.Warn or WorkerLogLevel.Error))
|
||||||
|
continue;
|
||||||
|
Rows.Insert(0, new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateStatusText();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The longest run of `previous`'s tail that reappears as `latest`'s head — i.e. how much of
|
||||||
|
// the old snapshot survived unevicted. 0 when nothing overlaps (e.g. right after opening).
|
||||||
|
private static int FindOverlap(IReadOnlyList<WorkerLogEntry> previous, IReadOnlyList<WorkerLogEntry> latest)
|
||||||
|
{
|
||||||
|
for (var k = Math.Min(previous.Count, latest.Count); k > 0; k--)
|
||||||
|
{
|
||||||
|
var matches = true;
|
||||||
|
for (var i = 0; i < k; i++)
|
||||||
|
{
|
||||||
|
if (!previous[previous.Count - k + i].Equals(latest[i])) { matches = false; break; }
|
||||||
|
}
|
||||||
|
if (matches) return k;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStatusText() =>
|
||||||
StatusText = Rows.Count == 0
|
StatusText = Rows.Count == 0
|
||||||
? Loc.T("modals.logVisualizer.empty")
|
? Loc.T("modals.logVisualizer.empty")
|
||||||
: Loc.T("modals.logVisualizer.count", Rows.Count);
|
: Loc.T("modals.logVisualizer.count", Rows.Count);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Close()
|
||||||
|
{
|
||||||
|
StopReconcileTick();
|
||||||
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
/// <summary>Stops the reconcile tick. Also called from the dialog's native-close fallback
|
||||||
|
/// (WindowDialogService), which bypasses <see cref="CloseCommand"/> — otherwise an Alt+F4
|
||||||
|
/// leaves the timer polling the worker on an orphaned VM. Idempotent.</summary>
|
||||||
|
internal void StopReconcileTick()
|
||||||
|
{
|
||||||
|
_reconcileTimer.Stop();
|
||||||
|
_reconcileTimer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private bool CanCopyLast() => Rows.Count > 0;
|
private bool CanCopyLast() => Rows.Count > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string Title { get; init; }
|
public required string Title { get; init; }
|
||||||
public required string StatusText { get; init; }
|
public required string StatusText { get; init; }
|
||||||
|
public string? ParentTaskId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when the parent is also a candidate and rendered directly above this row.</summary>
|
||||||
|
public bool IsChild { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <see cref="ParentTaskId"/> is set but the parent isn't a candidate itself
|
||||||
|
/// (e.g. already Done/Cancelled) — the row still renders top-level, flagged with a hint.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsOrphanChild { get; init; }
|
||||||
|
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
}
|
}
|
||||||
@@ -28,6 +38,23 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private string _listId = "";
|
private string _listId = "";
|
||||||
|
|
||||||
|
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
|
||||||
|
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
|
||||||
|
|
||||||
|
// Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state
|
||||||
|
// doesn't cascade over its children's own remembered state.
|
||||||
|
private bool _suppressCascade;
|
||||||
|
|
||||||
|
// Only one tick may be in flight. A second one entering while the first awaits LoadAsync
|
||||||
|
// would snapshot an already-cleared Tasks collection, restore that emptiness, and let
|
||||||
|
// IsTickedByDefault re-tick every row — silently undoing the user's unticks, i.e. exactly
|
||||||
|
// what the remember/restore below exists to prevent.
|
||||||
|
private bool _tickInFlight;
|
||||||
|
|
||||||
|
// Test seam: awaited inside ReconcileTickAsync after the reload and before the restore, so a
|
||||||
|
// test can hold one tick open in exactly the window a second one would corrupt. Null in prod.
|
||||||
|
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _scopeLabel = "";
|
[ObservableProperty] private string _scopeLabel = "";
|
||||||
@@ -38,8 +65,18 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
|
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
||||||
|
|
||||||
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
=> _dbFactory = dbFactory;
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user ticks
|
||||||
|
// through candidates), so refresh it on the same cadence as TasksIslandViewModel's tick
|
||||||
|
// instead of leaving it frozen at the moment it was opened.
|
||||||
|
_reconcileTimer.Elapsed += (_, _) =>
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
|
||||||
|
_reconcileTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
public void Configure(string listId, string listName)
|
public void Configure(string listId, string listName)
|
||||||
{
|
{
|
||||||
@@ -47,10 +84,57 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
|
||||||
|
// the user's ticks out from under them on every tick — in BOTH directions. Remembering only
|
||||||
|
// the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/
|
||||||
|
// WaitingForReview/Failed all default to ticked), so carry the full state of every row that
|
||||||
|
// already existed across the reload; only genuinely new rows get the default.
|
||||||
|
internal async Task ReconcileTickAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_listId) || _tickInFlight) return;
|
||||||
|
_tickInFlight = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Hold the row INSTANCES, not a value snapshot, and read their state back only after
|
||||||
|
// the reload: a tick the user lands during the await happens before LoadAsync clears
|
||||||
|
// the collection, so the live instance still carries it. Snapshotting up front would
|
||||||
|
// drop that click.
|
||||||
|
var previousRows = Tasks.ToList();
|
||||||
|
await LoadAsync();
|
||||||
|
|
||||||
|
if (ReconcileTickTestBarrier is { } barrier) await barrier();
|
||||||
|
|
||||||
|
var previous = new Dictionary<string, bool>(previousRows.Count);
|
||||||
|
foreach (var t in previousRows) previous[t.Id] = t.IsSelected;
|
||||||
|
|
||||||
|
// Restore without the parent→children cascade: it would overwrite a child's own
|
||||||
|
// remembered state with its parent's, undoing exactly the per-child choice being
|
||||||
|
// restored.
|
||||||
|
_suppressCascade = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var t in Tasks)
|
||||||
|
if (previous.TryGetValue(t.Id, out var wasSelected))
|
||||||
|
t.IsSelected = wasSelected;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressCascade = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CanConfirm));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_tickInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
|
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
|
||||||
Tasks.Clear();
|
Tasks.Clear();
|
||||||
|
_childrenByParentId = new();
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var candidates = await ctx.Tasks.AsNoTracking()
|
var candidates = await ctx.Tasks.AsNoTracking()
|
||||||
@@ -59,21 +143,56 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
// Manual tasks are reminders the user owns — never offer them to the handler.
|
// Manual tasks are reminders the user owns — never offer them to the handler.
|
||||||
.Where(t => !t.IsManual)
|
.Where(t => !t.IsManual)
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.Select(t => new { t.Id, t.Title, t.Status })
|
.Select(t => new { t.Id, t.Title, t.Status, t.ParentTaskId })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var c in candidates)
|
var candidateIds = candidates.Select(c => c.Id).ToHashSet();
|
||||||
|
bool ParentIsCandidate(string? parentId) =>
|
||||||
|
!string.IsNullOrEmpty(parentId) && candidateIds.Contains(parentId);
|
||||||
|
|
||||||
|
var childrenByParent = candidates
|
||||||
|
.Where(c => ParentIsCandidate(c.ParentTaskId))
|
||||||
|
.GroupBy(c => c.ParentTaskId!)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// Hierarchy-ordered walk, same idea as TasksIslandViewModel.Regroup: top-level rows
|
||||||
|
// (including orphaned children whose parent isn't a candidate) interleaved with their
|
||||||
|
// visible children, preserving the SortOrder/CreatedAt order within each level.
|
||||||
|
var emitted = new HashSet<string>();
|
||||||
|
var ordered = new List<(string Id, string Title, TaskStatus Status, string? ParentTaskId)>();
|
||||||
|
void Emit(string id, string title, TaskStatus status, string? parentTaskId)
|
||||||
{
|
{
|
||||||
|
if (!emitted.Add(id)) return;
|
||||||
|
ordered.Add((id, title, status, parentTaskId));
|
||||||
|
if (childrenByParent.TryGetValue(id, out var kids))
|
||||||
|
foreach (var kid in kids)
|
||||||
|
Emit(kid.Id, kid.Title, kid.Status, kid.ParentTaskId);
|
||||||
|
}
|
||||||
|
foreach (var c in candidates.Where(c => !ParentIsCandidate(c.ParentTaskId)))
|
||||||
|
Emit(c.Id, c.Title, c.Status, c.ParentTaskId);
|
||||||
|
|
||||||
|
foreach (var c in ordered)
|
||||||
|
{
|
||||||
|
var isChild = ParentIsCandidate(c.ParentTaskId);
|
||||||
var row = new MergeHelperTaskRowViewModel
|
var row = new MergeHelperTaskRowViewModel
|
||||||
{
|
{
|
||||||
Id = c.Id,
|
Id = c.Id,
|
||||||
Title = c.Title,
|
Title = c.Title,
|
||||||
StatusText = c.Status.ToString(),
|
StatusText = c.Status.ToString(),
|
||||||
|
ParentTaskId = c.ParentTaskId,
|
||||||
|
IsChild = isChild,
|
||||||
|
IsOrphanChild = !isChild && !string.IsNullOrEmpty(c.ParentTaskId),
|
||||||
IsSelected = IsTickedByDefault(c.Status),
|
IsSelected = IsTickedByDefault(c.Status),
|
||||||
};
|
};
|
||||||
row.PropertyChanged += OnRowChanged;
|
row.PropertyChanged += OnRowChanged;
|
||||||
Tasks.Add(row);
|
Tasks.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_childrenByParentId = Tasks
|
||||||
|
.Where(r => r.IsChild)
|
||||||
|
.GroupBy(r => r.ParentTaskId!)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasTasks));
|
OnPropertyChanged(nameof(HasTasks));
|
||||||
OnPropertyChanged(nameof(CanConfirm));
|
OnPropertyChanged(nameof(CanConfirm));
|
||||||
}
|
}
|
||||||
@@ -85,8 +204,19 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected))
|
if (e.PropertyName != nameof(MergeHelperTaskRowViewModel.IsSelected)) return;
|
||||||
OnPropertyChanged(nameof(CanConfirm));
|
|
||||||
|
// Ticking/unticking a parent cascades to its children; a child can still be
|
||||||
|
// deselected on its own afterwards without affecting the parent's checkbox —
|
||||||
|
// ClaudeDo has no indeterminate/tri-state parent checkbox.
|
||||||
|
if (!_suppressCascade
|
||||||
|
&& sender is MergeHelperTaskRowViewModel row
|
||||||
|
&& _childrenByParentId.TryGetValue(row.Id, out var children))
|
||||||
|
{
|
||||||
|
foreach (var child in children) child.IsSelected = row.IsSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CanConfirm));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -104,6 +234,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Confirm()
|
private void Confirm()
|
||||||
{
|
{
|
||||||
|
StopReconcileTick();
|
||||||
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
|
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
@@ -111,7 +242,17 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Cancel()
|
private void Cancel()
|
||||||
{
|
{
|
||||||
|
StopReconcileTick();
|
||||||
Result.TrySetResult(null);
|
Result.TrySetResult(null);
|
||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also called from the dialog's native-close fallback (WindowDialogService), which resolves
|
||||||
|
// Result without going through Confirm/Cancel. Idempotent — Timer.Stop()/Dispose() tolerate
|
||||||
|
// repeated calls.
|
||||||
|
internal void StopReconcileTick()
|
||||||
|
{
|
||||||
|
_reconcileTimer.Stop();
|
||||||
|
_reconcileTimer.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _commitMessage = "";
|
[ObservableProperty] private string _commitMessage = "";
|
||||||
|
|
||||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
||||||
|
|
||||||
|
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
|
||||||
|
/// A merge whose list has a verify command can occupy this call for minutes — without this the
|
||||||
|
/// modal only greys the button out and looks dead.
|
||||||
|
[ObservableProperty] private string? _progressMessage;
|
||||||
|
|
||||||
[ObservableProperty] private string? _errorMessage;
|
[ObservableProperty] private string? _errorMessage;
|
||||||
[ObservableProperty] private string? _warningMessage;
|
[ObservableProperty] private string? _warningMessage;
|
||||||
[ObservableProperty] private string? _successMessage;
|
[ObservableProperty] private string? _successMessage;
|
||||||
@@ -58,6 +64,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
|
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The worker owns the default message — only it knows the task's commit type and the
|
||||||
|
// list name the scope is slugged from. The locale string stays as the offline fallback.
|
||||||
|
if (!string.IsNullOrWhiteSpace(targets.DefaultCommitMessage))
|
||||||
|
CommitMessage = targets.DefaultCommitMessage;
|
||||||
foreach (var b in targets.LocalBranches) Branches.Add(b);
|
foreach (var b in targets.LocalBranches) Branches.Add(b);
|
||||||
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
||||||
? targets.DefaultBranch
|
? targets.DefaultBranch
|
||||||
@@ -81,6 +91,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
WarningMessage = null;
|
WarningMessage = null;
|
||||||
SuccessMessage = null;
|
SuccessMessage = null;
|
||||||
|
// Subscribed only for the duration of the call: the worker's broadcast reaches every
|
||||||
|
// client, and a transient VM left on that event would outlive its window.
|
||||||
|
ProgressMessage = Loc.T("vm.merge.progressMerging");
|
||||||
|
_worker.MergeProgressEvent += OnMergeProgress;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _worker.MergeTaskAsync(
|
var result = await _worker.MergeTaskAsync(
|
||||||
@@ -116,6 +130,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
// point of the gate.
|
// point of the gate.
|
||||||
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
|
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
|
||||||
break;
|
break;
|
||||||
|
case "untracked_collision":
|
||||||
|
// Nothing landed -- the merge was refused before touching the repo. Show the
|
||||||
|
// real message (it names the colliding path and size); the locale string is
|
||||||
|
// only a fallback.
|
||||||
|
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.untrackedCollision");
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
|
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
|
||||||
break;
|
break;
|
||||||
@@ -127,10 +147,28 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||||
|
ProgressMessage = null;
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
|
||||||
|
{
|
||||||
|
if (taskId != TaskId) return;
|
||||||
|
ProgressMessage = phase switch
|
||||||
|
{
|
||||||
|
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
|
||||||
|
_ => Loc.T("vm.merge.progressMerging"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
|
||||||
|
private const string MergePhaseVerifying = "verifying";
|
||||||
|
|
||||||
|
private static string FormatElapsed(int seconds) =>
|
||||||
|
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Cancel() => CloseAction?.Invoke();
|
private void Cancel() => CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly List<string> _folders = new();
|
private readonly List<string> _folders = new();
|
||||||
|
|
||||||
|
public OperationStatus ScanOp { get; } = new();
|
||||||
|
|
||||||
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
@@ -96,6 +98,7 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
private async Task ScanAndAddAsync(IEnumerable<string> folders)
|
private async Task ScanAndAddAsync(IEnumerable<string> folders)
|
||||||
{
|
{
|
||||||
|
using var op = ScanOp.Begin(Loc.T("ops.repoImport.scanning"));
|
||||||
var current = new HashSet<string>(
|
var current = new HashSet<string>(
|
||||||
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
|
public OperationStatus RestoreOp { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
|
||||||
[ObservableProperty] private bool _hasCustomizedPrompts;
|
[ObservableProperty] private bool _hasCustomizedPrompts;
|
||||||
[ObservableProperty] private string? _viewedKindName;
|
[ObservableProperty] private string? _viewedKindName;
|
||||||
[ObservableProperty] private string _viewedContent = "";
|
[ObservableProperty] private string _viewedContent = "";
|
||||||
@@ -30,12 +31,19 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
RefreshCustomizedPrompts();
|
RefreshCustomizedPrompts();
|
||||||
|
RestoreOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) RestoreDefaultAgentsCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanRestoreDefaultAgents() => !RestoreOp.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))]
|
||||||
private async Task RestoreDefaultAgents()
|
private async Task RestoreDefaultAgents()
|
||||||
{
|
{
|
||||||
IsBusy = true; StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
using var op = RestoreOp.Begin(Loc.T("ops.skills.restoringDefaults"));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var r = await _worker.RestoreDefaultAgentsAsync();
|
var r = await _worker.RestoreDefaultAgentsAsync();
|
||||||
@@ -46,7 +54,6 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
|||||||
await _worker.RefreshAgentsAsync();
|
await _worker.RefreshAgentsAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.restoreFailed", ex.Message); }
|
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.restoreFailed", ex.Message); }
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _sunday;
|
[ObservableProperty] private bool _sunday;
|
||||||
[ObservableProperty] private TimeSpan _timeOfDay;
|
[ObservableProperty] private TimeSpan _timeOfDay;
|
||||||
[ObservableProperty] private DateTimeOffset? _lastRunAt;
|
[ObservableProperty] private DateTimeOffset? _lastRunAt;
|
||||||
|
[ObservableProperty] private string? _promptOverride;
|
||||||
|
|
||||||
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
|
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
|||||||
Sunday = (dto.Days & Sun) != 0;
|
Sunday = (dto.Days & Sun) != 0;
|
||||||
TimeOfDay = dto.TimeOfDay;
|
TimeOfDay = dto.TimeOfDay;
|
||||||
LastRunAt = dto.LastRunAt;
|
LastRunAt = dto.LastRunAt;
|
||||||
|
PromptOverride = dto.PromptOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int DaysMask()
|
public int DaysMask()
|
||||||
@@ -74,5 +76,6 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
public PrimeScheduleDto ToDto() =>
|
public PrimeScheduleDto ToDto() =>
|
||||||
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
|
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt,
|
||||||
|
string.IsNullOrWhiteSpace(PromptOverride) ? null : PromptOverride);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
|
public OperationStatus InstallOp { get; } = new();
|
||||||
|
public OperationStatus UpdateOp { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _installUrl = "";
|
[ObservableProperty] private string _installUrl = "";
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -17,7 +20,18 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
|
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
|
||||||
|
|
||||||
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
public SessionSkillsSettingsTabViewModel(IWorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
InstallOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) InstallCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
UpdateOp.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(OperationStatus.IsRunning)) UpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
{
|
{
|
||||||
@@ -32,11 +46,14 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
|||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanInstall() => !InstallOp.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanInstall))]
|
||||||
private async Task InstallAsync()
|
private async Task InstallAsync()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
|
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
|
||||||
IsBusy = true; StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
using var op = InstallOp.Begin(Loc.T("ops.skills.installing"));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
|
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
|
||||||
@@ -45,14 +62,16 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
|||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
|
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
private bool CanUpdate(string? sourceUrl) => !UpdateOp.IsRunning;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanUpdate))]
|
||||||
private async Task UpdateAsync(string? sourceUrl)
|
private async Task UpdateAsync(string? sourceUrl)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
||||||
IsBusy = true; StatusMessage = "";
|
StatusMessage = "";
|
||||||
|
using var op = UpdateOp.Begin(Loc.T("ops.skills.updating"));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _worker.UpdateSessionSkillAsync(sourceUrl);
|
await _worker.UpdateSessionSkillAsync(sourceUrl);
|
||||||
@@ -60,7 +79,6 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
|||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
|
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
|
||||||
|
public OperationStatus ResetStatus { get; } = new();
|
||||||
|
|
||||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||||
|
|
||||||
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||||
@@ -56,7 +58,8 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ConfirmResetAll()
|
private async Task ConfirmResetAll()
|
||||||
{
|
{
|
||||||
ShowResetConfirm = false; IsBusy = true; StatusMessage = "";
|
ShowResetConfirm = false; StatusMessage = "";
|
||||||
|
using var op = ResetStatus.Begin(Loc.T("ops.worktrees.resetting"));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var r = await _worker.ResetAllWorktreesAsync();
|
var r = await _worker.ResetAllWorktreesAsync();
|
||||||
@@ -65,6 +68,5 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
|||||||
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
|
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
|
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user