feat: worker CLI modernization + UI fixes

Merge feat/worker-cli-modernization into main.

Worker: StreamAnalyzer, ClaudeArgsBuilder, config resolution,
auto-retry, multi-turn continue, agent file management.

UI: StreamLineFormatter for NDJSON display, LiveText replaces
LiveLines, start feedback, log reload, config editors with
model/prompt/agent fields, modal theming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-14 17:03:47 +02:00
71 changed files with 7822 additions and 204 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

View File

@@ -0,0 +1,106 @@
<h2>Green-Teal Variations</h2>
<p class="subtitle">Steel Teal shifted greener. Pick the one that feels right.</p>
<div class="cards">
<div class="card" data-choice="forest-teal" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#3d9474;display:inline-block;margin-top:4px;"></span>
<span style="color:#6bb89e; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(61,148,116,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #3d9474;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" stroke="#3d9474" stroke-width="2" fill="none"/></svg>
</div>
<div>
<div style="color:#5a6578;font-weight:500;text-decoration:line-through;">Setup CI</div>
<div style="font-size:11px;color:#475569;">Done</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#3d9474;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Forest Teal</h3>
<p>Accent: <code>#3d9474</code>. Distinctly greener, still muted. Earthy.</p>
</div>
</div>
<div class="card" data-choice="jade" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#4a9880;display:inline-block;margin-top:4px;"></span>
<span style="color:#72baa4; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(74,152,128,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #4a9880;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" stroke="#4a9880" stroke-width="2" fill="none"/></svg>
</div>
<div>
<div style="color:#5a6578;font-weight:500;text-decoration:line-through;">Setup CI</div>
<div style="font-size:11px;color:#475569;">Done</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#4a9880;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Jade</h3>
<p>Accent: <code>#4a9880</code>. Balanced green-teal midpoint. Calm but not cold.</p>
</div>
</div>
<div class="card" data-choice="sage" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#5a9a7a;display:inline-block;margin-top:4px;"></span>
<span style="color:#82bc9e; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(90,154,122,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #5a9a7a;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" stroke="#5a9a7a" stroke-width="2" fill="none"/></svg>
</div>
<div>
<div style="color:#5a6578;font-weight:500;text-decoration:line-through;">Setup CI</div>
<div style="font-size:11px;color:#475569;">Done</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#5a9a7a;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Sage</h3>
<p>Accent: <code>#5a9a7a</code>. Most green of the three. Softer, natural tone.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,107 @@
<h2>Accent Color: Which tone?</h2>
<p class="subtitle">You want something dimmer than the indigo (#6366f1) I showed. Here are darker, more muted options — each shown on a task list mockup.</p>
<div class="cards">
<div class="card" data-choice="slate-blue" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<!-- Sidebar slice -->
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#4b5ea8;display:inline-block;margin-top:4px;"></span>
<span style="color:#8b9dd4; font-weight:500;">My Project</span>
</div>
<!-- Task row -->
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(75,94,168,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<!-- Add task -->
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#4b5ea8;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Slate Blue</h3>
<p>Muted blue-gray. Accent: <code>#4b5ea8</code>. Very subdued, professional. Close to VS Code's dark theme feel.</p>
</div>
</div>
<div class="card" data-choice="dim-violet" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#7c6aad;display:inline-block;margin-top:4px;"></span>
<span style="color:#a899cc; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(124,106,173,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#7c6aad;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Dim Violet</h3>
<p>Muted purple. Accent: <code>#7c6aad</code>. Slightly warmer, still understated. Has a subtle "Claude" vibe.</p>
</div>
</div>
<div class="card" data-choice="steel-teal" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#4a8c8c;display:inline-block;margin-top:4px;"></span>
<span style="color:#7fb8b8; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(74,140,140,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#4a8c8c;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Steel Teal</h3>
<p>Muted teal-green. Accent: <code>#4a8c8c</code>. Cool and calm. Distinct from typical blue-heavy dark UIs.</p>
</div>
</div>
<div class="card" data-choice="charcoal-blue" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 16px; background: #0f0f1e;">
<div style="font-family: system-ui; font-size: 13px;">
<div style="display:flex; gap: 12px; margin-bottom: 12px;">
<span style="width:8px;height:8px;border-radius:50%;background:#5571a1;display:inline-block;margin-top:4px;"></span>
<span style="color:#8ba4c8; font-weight:500;">My Project</span>
</div>
<div style="padding: 10px 14px; display:flex; align-items:center; gap:10px; border-radius:8px; background:rgba(85,113,161,0.1); margin-bottom:6px;">
<div style="width:20px;height:20px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<div>
<div style="color:#c8d0e0;font-weight:500;">Fix login bug</div>
<div style="font-size:11px;color:#5a6578;">agent</div>
</div>
</div>
<div style="padding:10px 14px;border:1px dashed #3a4560;border-radius:8px;color:#5a6578;display:flex;align-items:center;gap:8px;font-size:13px;">
<span style="color:#5571a1;">+</span> Add a task...
</div>
</div>
</div>
<div class="card-body">
<h3>Charcoal Blue</h3>
<p>Desaturated steel blue. Accent: <code>#5571a1</code>. Very close to Microsoft To Do's dark mode accent but dimmer.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,169 @@
<h2>Layout & Visual Design Direction</h2>
<p class="subtitle">Your current UI has button toolbars and minimal spacing. Which direction should we take?</p>
<div class="split">
<div class="mockup">
<div class="mockup-header">Current: Toolbar Style</div>
<div class="mockup-body" style="font-family: system-ui; font-size: 13px;">
<div style="display: flex; height: 340px;">
<!-- Lists -->
<div style="width: 160px; border-right: 1px solid #444; display:flex; flex-direction:column;">
<div style="padding: 6px 8px; font-weight: bold; font-size: 13px;">Lists</div>
<div style="flex:1; padding: 4px;">
<div style="padding: 6px 8px; background: #3a3a5c; border-radius: 3px; margin-bottom: 2px;">My Project</div>
<div style="padding: 6px 8px;">Backend Work</div>
<div style="padding: 6px 8px;">UI Polish</div>
</div>
<div style="padding: 6px; border-top: 1px solid #444; display:flex; gap: 4px;">
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">+</button>
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">E</button>
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">-</button>
</div>
</div>
<!-- Tasks -->
<div style="flex: 1; display:flex; flex-direction:column;">
<div style="padding: 6px 8px; font-weight: bold; font-size: 13px;">Tasks</div>
<div style="flex:1; padding: 4px;">
<div style="padding: 6px; display:flex; justify-content: space-between; align-items: center; margin-bottom: 2px;">
<div>
<div style="font-weight: 600;">Fix login bug</div>
<div style="font-size: 10px; color: #888;">agent</div>
</div>
<div style="display:flex; gap: 4px; align-items:center;">
<span style="background: #e67e22; color: white; padding: 1px 6px; border-radius: 3px; font-size: 11px;">Running</span>
<button style="padding: 1px 8px; font-size: 11px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">Run</button>
</div>
</div>
<div style="padding: 6px; display:flex; justify-content: space-between; align-items: center;">
<div><div style="font-weight: 600;">Add dark mode</div></div>
<div style="display:flex; gap: 4px; align-items:center;">
<span style="background: #666; color: white; padding: 1px 6px; border-radius: 3px; font-size: 11px;">Manual</span>
<button style="padding: 1px 8px; font-size: 11px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">Run</button>
</div>
</div>
</div>
<div style="padding: 6px; border-top: 1px solid #444; display:flex; gap: 4px;">
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">+ Task</button>
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">Edit</button>
<button style="padding: 2px 10px; font-size: 12px; background: #555; color: white; border: 1px solid #666; border-radius: 3px;">Delete</button>
</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">Proposed: To Do Style</div>
<div class="mockup-body" style="font-family: system-ui; font-size: 13px;">
<div style="display: flex; height: 340px;">
<!-- Lists sidebar -->
<div style="width: 160px; border-right: 1px solid #333; background: #1a1a2e; display:flex; flex-direction:column;">
<div style="padding: 14px 16px 10px; font-weight: 600; font-size: 14px; color: #94a3b8;">Lists</div>
<div style="flex:1; padding: 4px 8px;">
<div style="padding: 10px 12px; background: rgba(99,102,241,0.15); border-radius: 8px; margin-bottom: 4px; color: #a5b4fc; font-weight: 500; display:flex; align-items:center; gap:8px;">
<span style="width:8px;height:8px;border-radius:50%;background:#6366f1;display:inline-block;"></span>
My Project
</div>
<div style="padding: 10px 12px; border-radius: 8px; display:flex; align-items:center; gap:8px; color: #94a3b8;">
<span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;"></span>
Backend Work
</div>
<div style="padding: 10px 12px; border-radius: 8px; display:flex; align-items:center; gap:8px; color: #94a3b8;">
<span style="width:8px;height:8px;border-radius:50%;background:#f59e0b;display:inline-block;"></span>
UI Polish
</div>
</div>
<div style="padding: 8px 12px; border-top: 1px solid #333;">
<div style="padding: 8px 12px; color: #6366f1; cursor:pointer; border-radius: 6px; display:flex; align-items:center; gap: 6px; font-size: 13px;">
<span style="font-size: 16px;">+</span> New List
</div>
</div>
</div>
<!-- Tasks -->
<div style="flex: 1; display:flex; flex-direction:column; background: #16162a;">
<div style="padding: 14px 16px 10px; font-weight: 600; font-size: 16px; color: #e2e8f0;">My Project</div>
<div style="flex:1; padding: 0 8px;">
<div style="padding: 10px 12px; display:flex; align-items: center; gap: 10px; margin-bottom: 2px; border-radius: 8px; background: rgba(99,102,241,0.08);">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #e67e22; display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<div style="width:8px;height:8px;border-radius:50%;background:#e67e22;"></div>
</div>
<div style="flex:1;">
<div style="font-weight: 500; color: #e2e8f0;">Fix login bug</div>
<div style="font-size: 11px; color: #64748b; margin-top: 2px;">agent &middot; Running</div>
</div>
</div>
<div style="padding: 10px 12px; display:flex; align-items: center; gap: 10px; border-radius: 8px;">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #475569; flex-shrink:0;"></div>
<div style="flex:1;">
<div style="font-weight: 500; color: #e2e8f0;">Add dark mode</div>
<div style="font-size: 11px; color: #64748b;">manual</div>
</div>
</div>
<div style="padding: 10px 12px; display:flex; align-items: center; gap: 10px; border-radius: 8px; opacity: 0.5;">
<div style="width: 20px; height: 20px; border-radius: 50%; border: 2px solid #22c55e; display:flex; align-items:center; justify-content:center; flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" stroke="#22c55e" stroke-width="2" fill="none"/></svg>
</div>
<div style="flex:1;">
<div style="font-weight: 500; color: #64748b; text-decoration: line-through;">Setup CI pipeline</div>
<div style="font-size: 11px; color: #475569;">agent &middot; Done</div>
</div>
</div>
</div>
<!-- Inline add -->
<div style="padding: 8px 12px; border-top: 1px solid #333;">
<div style="padding: 10px 14px; border: 1px dashed #475569; border-radius: 8px; color: #64748b; display:flex; align-items: center; gap: 8px; font-size: 13px;">
<span style="font-size: 16px; color: #6366f1;">+</span> Add a task...
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<h3 style="margin-top: 32px;">Key Changes in the Proposed Design</h3>
<div class="options" data-multiselect>
<div class="option" data-choice="checkbox" onclick="toggleSelect(this)">
<div class="letter">1</div>
<div class="content">
<h3>Circular Checkboxes</h3>
<p>Replace status badges with circular checkboxes on the left. Border color reflects status (orange=running, green=done, gray=manual). Click to toggle done.</p>
</div>
</div>
<div class="option" data-choice="inline-add" onclick="toggleSelect(this)">
<div class="letter">2</div>
<div class="content">
<h3>Inline "Add a task" Input</h3>
<p>Dashed border text field pinned at the bottom of the task list. Always visible. Enter to create, Escape to cancel.</p>
</div>
</div>
<div class="option" data-choice="list-header" onclick="toggleSelect(this)">
<div class="letter">3</div>
<div class="content">
<h3>List Name as Tasks Header</h3>
<p>Replace generic "Tasks" header with the selected list name in larger text. Matches To Do's pattern.</p>
</div>
</div>
<div class="option" data-choice="sidebar-polish" onclick="toggleSelect(this)">
<div class="letter">4</div>
<div class="content">
<h3>Sidebar Polish</h3>
<p>Colored dots per list, subtle highlight on selected, "+ New List" link at bottom instead of +/E/- buttons.</p>
</div>
</div>
<div class="option" data-choice="remove-buttons" onclick="toggleSelect(this)">
<div class="letter">5</div>
<div class="content">
<h3>Remove Button Toolbars</h3>
<p>Eliminate the bottom button bars from both panes. All actions via context menu, keyboard shortcuts, or inline controls.</p>
</div>
</div>
<div class="option" data-choice="done-style" onclick="toggleSelect(this)">
<div class="letter">6</div>
<div class="content">
<h3>Completed Task Styling</h3>
<p>Done tasks get strikethrough text, reduced opacity, green checkmark. Keeps them visible but visually subordinate.</p>
</div>
</div>
</div>
<p class="subtitle" style="margin-top: 16px;">This is multi-select — pick all the changes you'd like to include. I recommend all 6.</p>

View File

@@ -0,0 +1,61 @@
<h2>Task Creation: How should adding tasks work?</h2>
<p class="subtitle">Microsoft To Do uses an inline text field at the bottom of the task list. Currently ClaudeDo opens a modal dialog. Which approach fits best?</p>
<div class="options">
<div class="option" data-choice="a" onclick="toggleSelect(this)">
<div class="letter">A</div>
<div class="content">
<h3>Inline Add (To Do style)</h3>
<p>A text field always visible at the bottom of the task list. Press <strong>Enter</strong> to create a quick task with just a title. Tab or click to expand for more fields (description, tags, status).</p>
<div class="pros-cons">
<div class="pros"><h4>Pros</h4><ul>
<li>Fastest for rapid task entry</li>
<li>Keyboard-driven — never leave the list</li>
<li>Feels natural and lightweight</li>
</ul></div>
<div class="cons"><h4>Cons</h4><ul>
<li>Limited space for advanced fields</li>
<li>Need separate flow for setting tags/status on creation</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="b" onclick="toggleSelect(this)">
<div class="letter">B</div>
<div class="content">
<h3>Inline Add + Detail Pane Editing</h3>
<p>Same inline text field for quick creation. After pressing Enter, the new task is selected and the <strong>detail pane on the right</strong> becomes editable — add description, tags, commit type there. Like To Do's "click task → edit in sidebar" pattern.</p>
<div class="pros-cons">
<div class="pros"><h4>Pros</h4><ul>
<li>Quick entry AND full editing without modals</li>
<li>Uses existing detail pane real estate</li>
<li>Closest to Microsoft To Do's actual flow</li>
</ul></div>
<div class="cons"><h4>Cons</h4><ul>
<li>Detail pane needs to become editable (currently read-only)</li>
<li>More complex state management</li>
</ul></div>
</div>
</div>
</div>
<div class="option" data-choice="c" onclick="toggleSelect(this)">
<div class="letter">C</div>
<div class="content">
<h3>Keep Modal Dialog + Keyboard Shortcut</h3>
<p>Keep the existing modal editor but add <strong>Ctrl+N</strong> / <strong>Enter</strong> shortcut to open it instantly. Add keyboard navigation within the dialog (Tab between fields, Enter to save).</p>
<div class="pros-cons">
<div class="pros"><h4>Pros</h4><ul>
<li>Minimal code changes</li>
<li>All fields visible at once</li>
<li>Modal keeps focus clear</li>
</ul></div>
<div class="cons"><h4>Cons</h4><ul>
<li>Still interrupts flow with a window</li>
<li>Feels heavier than To Do</li>
</ul></div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776154800894}

View File

@@ -0,0 +1 @@
1955

View File

@@ -0,0 +1,79 @@
<h2>Island-Farben: weniger grün</h2>
<p class="subtitle">Gleiche Struktur, neutralere Grautöne mit nur einem Hauch Grün</p>
<div class="cards">
<div class="card" data-choice="neutral-slate" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 10px; background: #1b1e23;">
<div style="display: flex; gap: 8px; height: 180px; font-family: system-ui; font-size: 12px;">
<div style="flex: 1; background: #252a30; border-radius: 12px; padding: 10px;">
<div style="color: #94a3b8; font-size: 11px; margin-bottom: 8px;">Lists</div>
<div style="padding: 6px 8px; background: rgba(61,148,116,0.12); border-radius: 6px; color: #6bb89e; font-size: 12px;">My Project</div>
</div>
<div style="flex: 2; background: #252a30; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px; margin-bottom: 8px;">My Project</div>
<div style="padding: 6px; display:flex; align-items:center; gap:6px;">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<span style="color:#e2e8f0;">Fix login bug</span>
</div>
</div>
<div style="flex: 1.2; background: #252a30; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px;">Detail</div>
</div>
</div>
</div>
<div class="card-body">
<h3>Neutral Slate</h3>
<p>Base: <code>#1b1e23</code> · Islands: <code>#252a30</code><br>Fast kein Grün — kühl, neutral, wie VS Code Dark+</p>
</div>
</div>
<div class="card" data-choice="warm-charcoal" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 10px; background: #1c1e21;">
<div style="display: flex; gap: 8px; height: 180px; font-family: system-ui; font-size: 12px;">
<div style="flex: 1; background: #272a2e; border-radius: 12px; padding: 10px;">
<div style="color: #94a3b8; font-size: 11px; margin-bottom: 8px;">Lists</div>
<div style="padding: 6px 8px; background: rgba(61,148,116,0.12); border-radius: 6px; color: #6bb89e; font-size: 12px;">My Project</div>
</div>
<div style="flex: 2; background: #272a2e; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px; margin-bottom: 8px;">My Project</div>
<div style="padding: 6px; display:flex; align-items:center; gap:6px;">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<span style="color:#e2e8f0;">Fix login bug</span>
</div>
</div>
<div style="flex: 1.2; background: #272a2e; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px;">Detail</div>
</div>
</div>
</div>
<div class="card-body">
<h3>Warm Charcoal</h3>
<p>Base: <code>#1c1e21</code> · Islands: <code>#272a2e</code><br>Minimal warm, komplett neutral. Wie Rider's New UI Dark.</p>
</div>
</div>
<div class="card" data-choice="subtle-tint" onclick="toggleSelect(this)">
<div class="card-image" style="padding: 10px; background: #1b1f22;">
<div style="display: flex; gap: 8px; height: 180px; font-family: system-ui; font-size: 12px;">
<div style="flex: 1; background: #262b2d; border-radius: 12px; padding: 10px;">
<div style="color: #94a3b8; font-size: 11px; margin-bottom: 8px;">Lists</div>
<div style="padding: 6px 8px; background: rgba(61,148,116,0.12); border-radius: 6px; color: #6bb89e; font-size: 12px;">My Project</div>
</div>
<div style="flex: 2; background: #262b2d; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px; margin-bottom: 8px;">My Project</div>
<div style="padding: 6px; display:flex; align-items:center; gap:6px;">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #475569;flex-shrink:0;"></div>
<span style="color:#e2e8f0;">Fix login bug</span>
</div>
</div>
<div style="flex: 1.2; background: #262b2d; border-radius: 12px; padding: 10px;">
<div style="color: #e2e8f0; font-size: 14px;">Detail</div>
</div>
</div>
</div>
<div class="card-body">
<h3>Subtle Tint</h3>
<p>Base: <code>#1b1f22</code> · Islands: <code>#262b2d</code><br>Ganz leichter kühler Ton — zwischen den anderen beiden</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<h2>Island Layout (Rider-Style)</h2>
<p class="subtitle">Dark greenish-gray base, rounded card panels floating on top</p>
<div class="split">
<div class="mockup">
<div class="mockup-header">Current: Flat columns, black gaps</div>
<div class="mockup-body" style="font-family: system-ui; font-size: 13px; background: #000; padding: 0;">
<div style="display: flex; height: 300px;">
<div style="width: 140px; background: #1a1a2e; padding: 10px;">
<div style="color: #94a3b8; font-size: 12px; margin-bottom: 8px;">Lists</div>
<div style="padding: 8px; background: rgba(61,148,116,0.15); border-radius: 4px; color: #6bb89e; font-size: 13px; margin-bottom: 4px;">My Project</div>
<div style="padding: 8px; color: #94a3b8; font-size: 13px;">Backend</div>
</div>
<div style="width: 4px; background: #000;"></div>
<div style="flex: 1; background: #16162a; padding: 10px;">
<div style="color: #e2e8f0; font-size: 15px; margin-bottom: 8px;">My Project</div>
<div style="padding: 8px; color: #e2e8f0; font-size: 13px;">Fix login bug</div>
</div>
<div style="width: 4px; background: #000;"></div>
<div style="width: 160px; background: #16162a; padding: 10px;">
<div style="color: #e2e8f0; font-size: 15px; margin-bottom: 8px;">Detail</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">Proposed: Floating islands on tinted base</div>
<div class="mockup-body" style="font-family: system-ui; font-size: 13px; background: #1a2420; padding: 8px;">
<div style="display: flex; height: 300px; gap: 8px;">
<div style="width: 140px; background: #222d29; border-radius: 12px; padding: 12px;">
<div style="color: #94a3b8; font-size: 12px; margin-bottom: 10px;">Lists</div>
<div style="padding: 8px 10px; background: rgba(61,148,116,0.15); border-radius: 8px; color: #6bb89e; font-size: 13px; margin-bottom: 4px;">My Project</div>
<div style="padding: 8px 10px; color: #94a3b8; font-size: 13px; border-radius: 8px;">Backend</div>
</div>
<div style="flex: 1; background: #222d29; border-radius: 12px; padding: 12px;">
<div style="color: #e2e8f0; font-size: 15px; margin-bottom: 10px;">My Project</div>
<div style="padding: 8px 10px; display: flex; align-items: center; gap: 8px; border-radius: 8px;">
<div style="width: 18px; height: 18px; border-radius: 50%; border: 2px solid #475569; flex-shrink: 0;"></div>
<div>
<div style="color: #e2e8f0; font-size: 13px;">Fix login bug</div>
<div style="color: #5a6578; font-size: 11px;">agent · manual</div>
</div>
</div>
<div style="padding: 8px 10px; display: flex; align-items: center; gap: 8px; border-radius: 8px; opacity: 0.6;">
<div style="width: 18px; height: 18px; border-radius: 50%; border: 2px solid #3d9474; display:flex;align-items:center;justify-content:center; flex-shrink: 0;">
<svg width="10" height="10" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" stroke="#3d9474" stroke-width="2" fill="none"/></svg>
</div>
<div>
<div style="color: #5a6578; font-size: 13px; text-decoration: line-through;">Setup CI</div>
<div style="color: #475569; font-size: 11px;">done</div>
</div>
</div>
</div>
<div style="width: 160px; background: #222d29; border-radius: 12px; padding: 12px;">
<div style="color: #e2e8f0; font-size: 15px; margin-bottom: 10px;">Fix login bug</div>
<div style="color: #94a3b8; font-size: 12px;">Status</div>
<div style="color: #e2e8f0; font-size: 13px; margin-bottom: 8px;">Manual</div>
<div style="color: #94a3b8; font-size: 12px;">Tags</div>
<div style="display:flex; gap:4px; margin-top: 4px;">
<span style="background: rgba(61,148,116,0.15); color: #6bb89e; padding: 2px 8px; border-radius: 10px; font-size: 11px;">agent</span>
</div>
</div>
</div>
</div>
</div>
</div>
<h3 style="margin-top: 24px;">Die Änderungen</h3>
<ul style="color: #cbd5e1; line-height: 1.8;">
<li><strong>Window-Background:</strong> <code>#1a2420</code> — dunkles Grau mit Grünstich</li>
<li><strong>Island-Background:</strong> <code>#222d29</code> — etwas heller, ebenfalls grünlich</li>
<li><strong>Border-Radius:</strong> 12px auf allen drei Spalten</li>
<li><strong>Gap:</strong> 8px zwischen den Islands (GridSplitter entfernen, Margin nutzen)</li>
<li><strong>Padding:</strong> 8px um das gesamte Grid (Window-Rand)</li>
<li><strong>GridSplitter weg</strong> — die Islands haben feste Abstände, Resizing via Window-Größe</li>
</ul>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776158304159}

View File

@@ -0,0 +1 @@
3761

54
CLAUDE.md Normal file
View File

@@ -0,0 +1,54 @@
# ClaudeDo
A desktop task management app that executes tasks autonomously via Claude CLI in isolated git worktrees.
## Architecture
Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.App** — Avalonia desktop entry point, DI container setup
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
## Tech Stack
- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
- SignalR for real-time IPC
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
- Git worktrees for task isolation
## Key Paths
- DB: `~/.todo-app/todo.db`
- UI config: `~/.todo-app/ui.config.json`
- Worker config: `~/.todo-app/worker.config.json`
- Logs: `~/.todo-app/logs/`
- Worktrees: configured per worker (sibling or central strategy)
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
## Conventions
- Repository pattern — each entity has its own async repository
- All data operations are async with CancellationToken support
- Task status flow: Manual | Queued -> Running -> Done | Failed
- Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
- Commit messages use conventional format: `{commitType}(slug): title`
- Views use compiled bindings (`x:DataType`)
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
## Building & Testing
```bash
dotnet build ClaudeDo.slnx
dotnet test tests/ClaudeDo.Worker.Tests
```
## Docs
- `docs/plan.md` — full architecture and design spec
- `docs/open.md` — verification checklist and improvement backlog
- `docs/improvement-plan.md` — prioritized improvement items

View File

@@ -6,6 +6,7 @@
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" /> <Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" /> <Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

107
README.md Normal file
View File

@@ -0,0 +1,107 @@
# ClaudeDo
A desktop task management app that executes tasks autonomously via [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) in isolated git worktrees.
Queue up coding tasks, and ClaudeDo picks them up one by one — each running in its own worktree so your main branch stays clean.
## Architecture
Two-process system communicating over SignalR:
| Project | Role |
|---|---|
| **ClaudeDo.App** | Avalonia desktop entry point, DI container setup |
| **ClaudeDo.Ui** | Views, ViewModels, SignalR client (MVVM) |
| **ClaudeDo.Data** | SQLite data layer, repositories, models, GitService |
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
```
┌──────────────┐ SignalR ┌──────────────┐
│ ClaudeDo.App│◄──────────►│ClaudeDo.Worker│
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET) │
│ │ :47821 │ │
│ ┌──────────┐│ │ ┌──────────┐ │
│ │ Ui ││ │ │ TaskQueue│ │
│ │(ViewModels)│ │ │ Claude CLI│ │
│ └──────────┘│ │ └──────────┘ │
└──────┬───────┘ └──────┬───────┘
│ │
└───────────┬───────────────┘
┌──────┴──────┐
│ ClaudeDo.Data│
│ (SQLite) │
└─────────────┘
```
## Tech Stack
- .NET 8.0
- Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
- SignalR for real-time IPC between UI and Worker
- CommunityToolkit.Mvvm for source-generated MVVM
- Git worktrees for task isolation
## Prerequisites
- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- Git
## Getting Started
```bash
# Build
dotnet build ClaudeDo.slnx
# Run tests
dotnet test tests/ClaudeDo.Worker.Tests
# Run the app
dotnet run --project src/ClaudeDo.App
```
## How It Works
1. Create a task in the UI and tag it with **"agent"** to mark it for automated execution.
2. The Worker picks up queued tasks and runs each one via Claude CLI in an isolated git worktree.
3. When done, the worktree can be merged, kept for review, or discarded.
**Task status flow:** `Manual | Queued → Running → Done | Failed`
**Worktree state flow:** `Active → Merged | Discarded | Kept`
## Configuration
All data and config lives under `~/.todo-app/`:
| File | Purpose |
|---|---|
| `todo.db` | SQLite database |
| `ui.config.json` | UI settings |
| `worker.config.json` | Worker settings (worktree strategy, etc.) |
| `logs/` | Application logs |
## Project Structure
```
ClaudeDo.slnx
├── src/
│ ├── ClaudeDo.App/ # Desktop entry point
│ ├── ClaudeDo.Ui/ # Views & ViewModels
│ ├── ClaudeDo.Data/ # Data access layer
│ └── ClaudeDo.Worker/ # Background task runner
├── tests/
│ └── ClaudeDo.Worker.Tests/
├── schema/
│ └── schema.sql # Database schema
└── docs/
├── plan.md # Architecture & design spec
├── open.md # Verification checklist & backlog
└── improvement-plan.md # Prioritized improvements
```
## License
Private — not licensed for redistribution.

92
docs/improvement-plan.md Normal file
View File

@@ -0,0 +1,92 @@
# ClaudeDo — Improvement Plan (Session 2026-04-13)
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
---
## P1 — UX-Blocker (sollten zuerst)
### IP-1: UI ↔ Worker Auto-Reconnect
**Symptom:** Wenn UI vor Worker startet, bleibt die Verbindung tot. Manueller UI-Restart nötig.
**Soll:** SignalR-Client mit `WithAutomaticReconnect()` + Reconnect-Versuche im Hintergrund (exponential backoff). Status-Bar zeigt "verbinde…" während Retry.
**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs` (oder wo `HubConnection` gebaut wird)
**Aufwand:** klein (~30 Zeilen, primär `HubConnectionBuilder`-Konfig + Reconnect-Handler)
**Risiko:** klein
### IP-2: Listen-Modus „Notes" (non-autonomous)
**Symptom:** Jede Liste ist Agent-gesteuert. Keine reine Notiz-Liste möglich.
**Soll:** Neues Feld `lists.kind` (`agent` | `notes`).
- `agent`: aktuelles Verhalten (Worker pickt Tasks)
- `notes`: Worker ignoriert die Liste komplett, UI versteckt Run-/Schedule-/Worktree-Felder, Tasks haben nur Title + Description + done-Checkbox.
**Dateien:**
- Schema: neue Spalte + Migration (siehe IP-9)
- `Data/Entities/TaskList.cs`, `Repositories/ListRepository.cs`
- `Worker/Queue/QueueService.cs` (Filter `WHERE list.kind = 'agent'`)
- UI: `ListEditorView` (Radio/ComboBox), `TaskListView` (conditional Columns), `TaskDetailView` (verstecken)
**Aufwand:** mittel (~Schema + Repo + UI an mehreren Stellen)
**Risiko:** mittel — bestehende Listen müssen Default `agent` bekommen
### IP-3: Doppelklick öffnet Edit-Dialog
**Symptom:** Edit nur über separaten Button/Menüpunkt.
**Soll:** `DoubleTapped`-Handler auf ListBox-Items (Listen-Pane) und auf TaskRows (Task-Pane) → öffnet jeweiligen Editor.
**Dateien:** `Views/MainWindow.axaml(.cs)`, `Views/TaskListView.axaml(.cs)`
**Aufwand:** klein (~1015 Zeilen pro Stelle)
**Risiko:** klein
### IP-4: Tag-Multi-Select statt Freitext
**Symptom:** Tags müssen getippt werden, keine Auto-Vervollständigung, Typos möglich.
**Soll:** Multi-Select-Control:
- Zeigt alle in DB existierenden Tags (DISTINCT aus `lists.tags` `tasks.tags`)
- Erlaubt Anlegen neuer Tags (Free-Text-Add)
- Chip/Token-Darstellung der ausgewählten Tags
**Dateien:**
- *neu* `Views/Controls/TagPicker.axaml` (wiederverwendbar)
- `ListEditorView`, `TaskEditorView` einbinden
- Repo-Methode `GetAllKnownTagsAsync()`
**Aufwand:** mittel (Custom-Control lohnt sich, da 2× verwendet)
**Risiko:** klein
### IP-5: Rechtsklick-Kontextmenü
**Symptom:** Quick-Actions nur über Buttons im Detail-Pane oder Toolbar.
**Soll:**
- **Liste:** Edit, Delete, New Task, ggf. „Mark all done" (für Notes-Listen aus IP-2)
- **Task:** Edit, Delete, Run Now, Show Diff, Merge, Cancel (je nach Status)
- Items kontext-sensitiv enabled/disabled je nach Task-Status & List-Kind
**Dateien:** `Views/MainWindow.axaml` (List-Pane), `Views/TaskListView.axaml` (Task-Pane)
**Aufwand:** kleinmittel — Avalonia `ContextMenu` + Command-Bindings
**Risiko:** klein
---
## P2 — Folge-Arbeiten (durch P1 ausgelöst)
### IP-6: Schema-Migration-Mechanismus
**Trigger:** IP-2 fügt eine Spalte zu `lists` hinzu. Aktuell `schema.sql` ist Drop-and-Create-Style.
**Soll:** Mini-Migrations-System: `migrations/0001_initial.sql`, `0002_lists_kind.sql`, … + `_schema_version` Tabelle.
**Aufwand:** kleinmittel
**Querverweis:** `open.md` Sektion 7 (Schulden-Tabelle: „Embedded schema.sql ohne Versionierung")
### IP-7: Status-Bar zeigt Reconnect-State
**Trigger:** IP-1 — User soll sehen, dass Verbindung gerade aufgebaut wird (statt nur „offline").
**Soll:** States: `connected` | `connecting` | `reconnecting` | `offline`. Farb-codiert.
**Datei:** `ViewModels/StatusBarViewModel.cs`
**Aufwand:** klein
### IP-8: Tag-Repository für `GetAllKnownTagsAsync`
**Trigger:** IP-4 braucht eine Quelle aller bekannten Tags.
**Soll:** Methode in `ListRepository`/`TaskRepository` ODER neuer `TagRepository`. SQL: `SELECT DISTINCT trim(value) FROM lists, json_each(lists.tags) UNION ...`.
**Aufwand:** klein
---
## Empfohlene Reihenfolge
1. **IP-1** (Auto-Reconnect) — sofortiger UX-Win, isoliert, klein
2. **IP-3** (Doppelklick) — trivial, sofort spürbar
3. **IP-5** (Kontextmenü) — kompakt, hebt Bedienkomfort deutlich
4. **IP-6** (Migrations) — Voraussetzung für IP-2
5. **IP-2** (Notes-Mode) — größerer Brocken, braucht Schema-Migration
6. **IP-8 → IP-4** (Tag-Repo, dann Multi-Select-Control)
7. **IP-7** (Reconnect-Status in StatusBar) — Polish nach IP-1
Block 1 (IP-1, IP-3, IP-5) ist ein realistischer Session-Block.

193
docs/open.md Normal file
View File

@@ -0,0 +1,193 @@
# ClaudeDo — Offene Punkte
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
---
## 1. Verification (vor allem anderen)
Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
| # | Plan | Status | Was tun |
|---|------|--------|---------|
| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
---
## 2. UI-Polish (kritisch für Benutzbarkeit)
Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
### 2.1 Folder-Picker für `Working Directory`
- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
- **Aufwand:** klein, ~30 Zeilen.
### 2.2 Delete-Confirmation
- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
### 2.3 Markdown-Rendering für Result + Description
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
### 2.4 Live-Log Auto-Scroll
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
- **Aufwand:** klein, ein attached behavior reicht.
### 2.5 Diff-Viewer
- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
### 2.6 Status-Bar Active-Tasks Live-Update
- **Datei:** `StatusBarViewModel`
- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
- **Aufwand:** klein, aber muss sauber gemacht werden.
### 2.7 Settings-Dialog
- **Datei:** *neu*`Views/SettingsDialog.axaml` + VM
- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
---
## 3. Worker-Robustheit
### 3.1 CLI-Preflight beim Worker-Start
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
### 3.2 Worktree-Cleanup beim Anlege-Failed
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
- **Aufwand:** klein.
### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
- **Datei:** alle Worker-Komponenten
- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
- **Aufwand:** klein.
### 3.4 Tag-Negation / Exclusion (Plan-TODO)
- **Plan-Sektion:** "Tag-Modell"
- **Aktuell:** Tags sind rein additiv (`list_tags task_tags`).
- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
---
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
### 4.1 Windows-Service-Hosting in Code
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
- **Soll:**
```csharp
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
builder.Logging.AddEventLog(...);
```
- **Aufwand:** klein.
### 4.2 Pfad-Auflösung absolut machen
- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
### 4.3 Install-Skripte / Doku
- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
- **Aufwand:** klein.
### 4.4 (später) Installer-Projekt
- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
---
## 5. Tests / CI
### 5.1 GitHub-Actions / Gitea-Actions Pipeline
- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
- **Aufwand:** klein.
### 5.2 Echter SignalR-Roundtrip-Test
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
- **Aufwand:** mittel.
### 5.3 Smoke-Test gegen echten `claude`
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
- **Aufwand:** klein.
---
## 6. Dokumentation
### 6.1 README.md
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
- **Aufwand:** klein.
### 6.2 `docs/architecture.md`
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
### 6.3 ADRs für die getroffenen Entscheidungen
- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
- **Aufwand:** klein, hilfreich für später.
---
## 7. Bekannte Code-Schulden / Smells
| Stelle | Issue |
|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
---
## Empfohlene Reihenfolge für die nächste Session
1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
Punkte 13 sind ein realistischer Block für eine Session.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
# UI Fixes Design Spec
Post-integration fixes for the Worker CLI modernization. Addresses four issues found during first real test.
## Issue 1: Raw NDJSON in Live Log
### Problem
`TaskDetailViewModel.OnTaskMessage` receives raw NDJSON lines from SignalR `TaskMessage` broadcasts and displays them as-is in an `ItemsControl`. Users see JSON like `{"type":"stream_event","event":{...}}` instead of readable output.
### Solution: StreamLineFormatter
New stateful helper class at `ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`.
**Responsibility:** Convert a single raw NDJSON line into human-readable text for display.
**API:**
```csharp
public sealed class StreamLineFormatter
{
// Returns formatted text to append, or null to skip the line.
public string? FormatLine(string ndjsonLine);
// Reads all lines from an NDJSON log file, formats each, returns complete text.
public string FormatFile(string filePath);
}
```
**Event mapping (moderate detail level):**
| NDJSON structure | Display output |
|---|---|
| `stream_event``content_block_delta``text_delta` | The delta text content (appended inline) |
| `stream_event``content_block_start``tool_use` | `\n[Tool: {name}]\n` |
| `stream_event``content_block_stop` | `\n` (line separator) |
| `stream_event``content_block_delta``input_json_delta` | null (skip — tool input noise) |
| `stream_event``message_start` / `message_delta` | null (skip) |
| `result` | `\n--- Result ---\n{result_text}\n` |
| `system` with `subtype: api_retry` | `\n[Retrying API call...]\n` |
| `assistant` | null (skip — content arrives via stream_events) |
| Malformed JSON / unknown type | Raw line as-is (fallback) |
**State tracking:** The formatter tracks whether the previous line was a text delta to avoid inserting unnecessary newlines between consecutive text chunks.
### Display model change
**Replace `ObservableCollection<string> LiveLines` with `[ObservableProperty] string _liveText = ""`.**
- `OnTaskMessage`: pass line through `_formatter.FormatLine(line)`, append result to `LiveText`
- Bounding: if `LiveText.Length > 50_000`, trim from the front at the next newline boundary
- View: replace `ItemsControl` with a read-only `TextBox` (`AcceptsReturn="True"`, `TextWrapping="NoWrap"`, monospace font)
- Auto-scroll to bottom on text change (code-behind handler on PropertyChanged)
**Rationale:** Text deltas stream per-token. An ItemsControl with hundreds of tiny entries causes UI overhead. A single TextBox with appended text gives a natural terminal feel and better performance.
---
## Issue 2: No Immediate Feedback on Task Start
### Problem
After clicking RunNow, nothing happens visually until the Worker processes the request, updates the DB, and broadcasts `TaskStarted`. The delay (typically <1s, but noticeable) makes the app feel unresponsive.
### Solution: Three-layer optimistic feedback
**Layer 1 — WorkerClient local event:**
Add `event Action<string>? RunNowRequestedEvent` to `WorkerClient`.
In `RunNowAsync(taskId)`: fire `RunNowRequestedEvent(taskId)` **before** calling `_hub.InvokeAsync("RunNow", taskId)`. This gives the UI an instant signal.
**Layer 2 — TaskItemViewModel (list view):**
- Add `[ObservableProperty] bool _isStarting`
- On `RunNowRequestedEvent` for this task: set `IsStarting = true`, `StatusText = "starting..."`
- On `TaskStartedEvent` for this task: set `IsStarting = false`
- `RunNowCommand.CanExecute` also returns false when `IsStarting` (prevents double-click)
- View: RunNow button disables and shows "Starting..." state
**Layer 3 — TaskDetailViewModel (detail view):**
- Subscribe to `RunNowRequestedEvent` → if current task, set `StatusText = "starting..."`, clear `LiveText`, reset formatter
- Subscribe to `TaskStartedEvent` → if current task, set `StatusText = "running"`
- Both are overwritten naturally when `OnTaskUpdated` fires and reloads from DB
**Wiring:** TaskListViewModel subscribes to `WorkerClient.RunNowRequestedEvent` and updates the matching `TaskItemViewModel`. TaskDetailViewModel subscribes directly to WorkerClient events (same pattern as existing TaskMessage/TaskUpdated subscriptions).
---
## Issue 3: Live Output Lost After Completion
### Problem
`LiveText` is in-memory only. Once the task finishes and the user navigates away, the log content is gone. The NDJSON log file exists on disk (at `task.LogPath`) but is never loaded back into the UI.
### Solution: Load from disk on revisit
In `TaskDetailViewModel.LoadAsync`, after loading the task entity:
```csharp
if (task.LogPath is not null
&& task.Status is TaskStatus.Done or TaskStatus.Failed
&& File.Exists(task.LogPath))
{
_formatter = new StreamLineFormatter();
LiveText = _formatter.FormatFile(task.LogPath);
}
```
**Reuses** `StreamLineFormatter.FormatFile` from Issue 1 — no new infrastructure needed.
**Edge cases:**
- **Task completes while watching:** LiveText already has streamed content. `OnTaskUpdated` triggers `LoadAsync`, which reloads from disk. Same content, re-parsed — no visible disruption.
- **Log file missing/deleted:** `File.Exists` check handles it. LiveText stays empty. The "Result" field above still shows result markdown from the DB.
- **Large log files:** Bounded by the same 50,000 char limit as live streaming. `FormatFile` applies the same trim-from-front logic.
---
## Issue 4: Config Editors + Modal Theming
### Problem A: Modal dialogs unstyled
`ListEditorView` and `TaskEditorView` are `<Window>` elements with no explicit background. They render as black with white text, not matching the app's green-accented dark theme defined in `App.axaml`.
### Fix: Apply app resource brushes
Both editor `<Window>` elements get:
- `Background="{StaticResource WindowBgBrush}"` (`#1c1e21`)
- Label TextBlocks: `Foreground="{StaticResource TextSecondaryBrush}"`
- Save button: `Background="{StaticResource AccentBrush}"` (green accent `#3d9474`)
- Cancel button: default Fluent dark theme (correct once window bg is set)
### Problem B: No UI for model/prompt/agent config
The backend supports per-list config (`list_config` table) and per-task overrides (`tasks.model`, `tasks.system_prompt`, `tasks.agent_path`), but there are no editor fields for these.
### Solution: Extend existing editors
#### Model selection
ComboBox shows short display labels, mapped to actual model IDs:
| Display | Model ID |
|---|---|
| Sonnet | `claude-sonnet-4-6` |
| Opus | `claude-opus-4-6` |
| Haiku | `claude-haiku-4-5` |
**Default model:** `Sonnet` (`claude-sonnet-4-6`). Applied in `TaskRunner` config resolution as the final fallback when both task and list config have no model set.
#### WorkerClient — add GetAgents
New methods on `WorkerClient`:
- `Task<List<AgentInfo>> GetAgentsAsync()` — calls hub `GetAgents()`
- `Task RefreshAgentsAsync()` — calls hub `RefreshAgents()`
- `record AgentInfo(string Name, string Description, string Path)` — DTO
#### ListEditorViewModel extensions
Three new properties:
- `[ObservableProperty] string _model` — ComboBox: Sonnet (default for new lists), Opus, Haiku
- `[ObservableProperty] string? _systemPrompt` — TextBox, multiline, optional
- `[ObservableProperty] string? _agentPath` — ComboBox populated from `GetAgentsAsync()`, empty = none
`InitForEdit` loads existing config via `ListRepository.GetConfigAsync()`.
`Save` persists via `ListRepository.SetConfigAsync()`.
#### TaskEditorViewModel extensions
Same three fields, but with inheritance indicators:
- Model ComboBox: first option `"(list default)"` → maps to null (inherit from list config, which falls back to Sonnet)
- SystemPrompt: placeholder text `"(inherits from list)"`
- AgentPath ComboBox: first option `"(list default)"` → maps to null
`InitForEdit` reads from `TaskEntity.Model/SystemPrompt/AgentPath`.
`Save` writes them back to the entity.
#### View layout
Both editors add an "Agent Config" section below existing fields, separated by a horizontal divider line. Contains: Model dropdown, System Prompt text area, Agent File picker. Always visible (no collapse — only three fields).
Window heights increase to accommodate new fields:
- ListEditorView: 280 → ~450
- TaskEditorView: 420 → ~580
---
## Out of Scope
- Agent file creation/editing UI (agents are managed as `.md` files on disk; editors only pick from existing agents)
- Token usage display in live output
- Run history viewer (multiple runs per task)
- Rich text rendering (markdown in result/output)
---
## Files Changed
### New
- `src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs`
### Modified
- `src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs` — LiveText, formatter, start feedback, log reload
- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml` — TextBox replaces ItemsControl, auto-scroll
- `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` — auto-scroll handler (if needed)
- `src/ClaudeDo.Ui/ViewModels/TaskItemViewModel.cs` — IsStarting property
- `src/ClaudeDo.Ui/Views/TaskListView.axaml` — starting state visual
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — RunNowRequestedEvent, GetAgentsAsync, RefreshAgentsAsync, AgentInfo DTO
- `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs` — config fields, agent loading
- `src/ClaudeDo.Ui/Views/ListEditorView.axaml` — config section, theming
- `src/ClaudeDo.Ui/ViewModels/TaskEditorViewModel.cs` — config override fields, agent loading
- `src/ClaudeDo.Ui/Views/TaskEditorView.axaml` — config section, theming
- `src/ClaudeDo.Ui/ViewModels/TaskListViewModel.cs` — wire RunNowRequestedEvent to TaskItemViewModels
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — default model fallback to `claude-sonnet-4-6`

View File

@@ -0,0 +1,514 @@
# Worker CLI Modernization
**Date:** 2026-04-14
**Status:** Approved
**Scope:** ClaudeDo.Worker — CLI invocation, execution tracking, per-task configuration, multi-turn support
## Problem
The Worker currently invokes Claude CLI with hardcoded flags (`-p --output-format stream-json --verbose --dangerously-skip-permissions`). There is no way to configure model, system prompt, or agent per list or task. Execution is single-shot with no retry or follow-up capability. Results are stored as a single markdown blob on the `tasks` row with no structured metadata, token usage, or turn count.
## Goals
1. Per-list configuration (model, system prompt, agent file) with per-task overrides
2. Execution history — each CLI invocation tracked as its own `task_runs` row
3. Multi-turn support — manual continue and auto-retry via `--resume`
4. Structured output alongside markdown via `--json-schema`
5. Agent file management — filesystem-based `.md` agents with UI to browse/create/edit
6. Richer stream parsing — token usage, turn count, session ID, retry events
## Non-Goals (Deferred)
- `--bare` mode (forces API key; user relies on OAuth/keychain auth)
- `--allowedTools` / permission modes (keep `--dangerously-skip-permissions`)
- Schema migration framework (use `IF NOT EXISTS` / `INSERT OR IGNORE` for additive changes)
---
## 1. Schema Changes
### 1.1 New table: `list_config`
One-to-one with `lists`. Stores per-list defaults for CLI invocation.
```sql
CREATE TABLE IF NOT EXISTS list_config (
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
model TEXT NULL, -- 'opus-4-6' | 'sonnet-4-6' | 'haiku-4-5'
system_prompt TEXT NULL, -- appended via --append-system-prompt
agent_path TEXT NULL -- path to agent .md file, passed via --agents
);
```
### 1.2 New columns on `tasks`
Per-task overrides. All nullable — NULL means "use list default".
```sql
ALTER TABLE tasks ADD COLUMN model TEXT NULL;
ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL;
ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL;
```
Since schema uses `IF NOT EXISTS` and is re-applied on startup, these are added via `ALTER TABLE ... ADD COLUMN` wrapped in a try/catch (SQLite raises "duplicate column" if already present — safe to ignore).
### 1.3 New table: `task_runs`
One row per CLI invocation. Supports multi-turn and retry tracking.
```sql
CREATE TABLE IF NOT EXISTS task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
run_number INTEGER NOT NULL, -- 1, 2, 3... sequential per task
session_id TEXT NULL, -- Claude CLI session ID (for --resume)
is_retry INTEGER NOT NULL DEFAULT 0, -- 0 = normal/continue, 1 = auto-retry
prompt TEXT NOT NULL, -- the prompt sent for this run
result_markdown TEXT NULL, -- free-form result from 'result' field
structured_output TEXT NULL, -- JSON from 'structured_output' field
error_markdown TEXT NULL, -- error output on failure
exit_code INTEGER NULL, -- CLI exit code
turn_count INTEGER NULL, -- number of agent loop turns
tokens_in INTEGER NULL, -- total input tokens
tokens_out INTEGER NULL, -- total output tokens
log_path TEXT NULL, -- NDJSON log file for this run
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
```
### 1.4 Denormalized fields on `tasks`
Keep existing `result`, `log_path`, `started_at`, `finished_at` on the `tasks` table. After each run completes, update them with the latest run's values. This preserves backward compatibility for UI queries that read `tasks` directly.
### 1.5 Model validation
Valid model values: `opus-4-6`, `sonnet-4-6`, `haiku-4-5`. Validated at the application layer (repository/service), not via SQL CHECK constraint, to allow easy future additions.
---
## 2. Agent File Management
### 2.1 Directory
Agents live in `~/.todo-app/agents/`. The directory is created on Worker startup if absent.
### 2.2 File format
Standard Claude agent markdown with YAML frontmatter:
```markdown
---
name: .NET Developer
description: Senior .NET developer focused on clean architecture
---
You are a senior .NET developer. Follow existing project patterns...
```
### 2.3 AgentFileService
New service in `ClaudeDo.Worker` (not a repository — operates on filesystem, not DB):
| Method | Description |
|--------|-------------|
| `ScanAsync()` | Returns `List<AgentInfo>` — parse frontmatter for name/description from all `*.md` in agents dir |
| `ReadAsync(string path)` | Full file content |
| `WriteAsync(string path, string content)` | Create or overwrite |
| `DeleteAsync(string path)` | Remove file |
### 2.4 AgentInfo DTO
```csharp
public sealed record AgentInfo(string Name, string Description, string Path);
```
### 2.5 Discovery
- Worker scans on startup and exposes agents via a new SignalR method `GetAgents()`.
- UI calls `GetAgents()` to populate dropdowns.
- A `RefreshAgents()` hub method triggers a re-scan (for after UI creates/edits a file).
---
## 3. CLI Invocation Changes
### 3.1 Current invocation
```
claude -p --output-format stream-json --verbose --dangerously-skip-permissions
```
Prompt written to stdin. Single-shot, no config, no structured output.
### 3.2 New invocation
Built dynamically per run by `ClaudeArgsBuilder`:
```
claude -p
--output-format stream-json
--verbose
--dangerously-skip-permissions
--model <resolved-model> # if set
--append-system-prompt <resolved-prompt> # if set
--agents '[{"file":"<resolved-agent-path>"}]' # if set
--json-schema <schema-json> # always
--resume <session-id> # only for multi-turn/retry
```
### 3.3 Config resolution
```
resolved_model = task.model ?? list_config.model ?? null (omit --model)
resolved_prompt = task.system_prompt ?? list_config.system_prompt ?? null (omit --append-system-prompt)
resolved_agent = task.agent_path ?? list_config.agent_path ?? null (omit --agents)
```
### 3.4 Structured output schema
Passed via `--json-schema` on every invocation:
```json
{
"type": "object",
"properties": {
"summary": { "type": "string" },
"files_changed": {
"type": "array",
"items": { "type": "string" }
},
"commit_type": { "type": "string" }
},
"required": ["summary"]
}
```
The CLI returns this in the `structured_output` field of the JSON result event. The markdown result remains in the `result` field.
### 3.5 ClaudeArgsBuilder
New class, single responsibility for argument construction:
```csharp
public sealed class ClaudeArgsBuilder
{
// Returns the full argument string for ProcessStartInfo.Arguments
public string Build(ClaudeRunConfig config);
}
public sealed record ClaudeRunConfig(
string? Model,
string? SystemPrompt,
string? AgentPath,
string? ResumeSessionId
);
```
Testable in isolation — no process spawning, just string building.
---
## 4. Stream Parsing
### 4.1 StreamAnalyzer (replaces MessageParser)
Processes each NDJSON line and accumulates metrics:
| Responsibility | How |
|---|---|
| Extract result markdown | Look for `type: "result"`, read `.result` field |
| Extract structured output | Same event, read `.structured_output` field |
| Extract session ID | Read `.session_id` from the result event |
| Count turns | Count events where `.type == "assistant"` |
| Accumulate tokens | Sum `.usage.input_tokens` and `.usage.output_tokens` from each turn |
| Track retries | Count `system/api_retry` events (informational logging) |
### 4.2 StreamResult
```csharp
public sealed class StreamResult
{
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? SessionId { get; set; }
public int TurnCount { get; set; }
public int TokensIn { get; set; }
public int TokensOut { get; set; }
public int ApiRetryCount { get; set; }
}
```
### 4.3 Extended RunResult
```csharp
public sealed class RunResult
{
public required int ExitCode { get; init; }
public string? ResultMarkdown { get; init; }
public string? ErrorMarkdown { get; init; }
public string? StructuredOutputJson { get; init; }
public string? SessionId { get; init; }
public int TurnCount { get; init; }
public int TokensIn { get; init; }
public int TokensOut { get; init; }
public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
}
```
---
## 5. Multi-Turn & Auto-Retry
### 5.1 Execution flow
```
Task queued
-> Run 1 (run_number=1, is_retry=0)
-> Resolve config (list defaults + task overrides)
-> Build CLI args (no --resume on first run)
-> Spawn claude, stream output, parse via StreamAnalyzer
-> Create task_runs row with all metrics
-> Update denormalized tasks fields
If failure (exit_code != 0):
-> Auto-retry: Run 2 (run_number=2, is_retry=1)
-> Prompt: "The previous attempt failed with:\n\n{error_markdown}\n\nTry again and fix the issues."
-> Uses --resume <session_id> from Run 1
-> Same worktree, same config
-> Create new task_runs row
-> If still fails: mark task Failed, stop
If success (exit_code == 0):
-> Auto-commit in worktree if changes
-> Mark task Done
User triggers "Continue" on finished/failed task:
-> New run (run_number=N+1, is_retry=0)
-> User-provided follow-up prompt
-> Uses --resume <session_id> from last run
-> Task status -> Running -> Done/Failed
```
### 5.2 Rules
- Max 1 auto-retry per task execution (no retry loops)
- Auto-retry reuses the session via `--resume` (full context of prior failure)
- Manual continue works on both Done and Failed tasks
- Each run gets its own log file: `{task_id}_run{N}.ndjson`
- Worktree commit happens only after a successful run
- If Run 1 has no session_id (edge case: CLI crashed before producing one), skip auto-retry
### 5.3 Continue via SignalR
New hub method: `ContinueTask(string taskId, string followUpPrompt)` -> returns `string runId`
Validation:
- Task must exist
- Task must not be currently running
- Previous run must have a session_id
---
## 6. TaskRunner Refactoring
### 6.1 Current flow (TaskRunner.RunAsync)
1. Load list, create worktree/sandbox, mark running
2. Build prompt from title + description
3. Call `_claude.RunAsync(prompt, dir, logPath, taskId, callback, ct)`
4. Handle result: commit on success, mark done/failed
### 6.2 New flow
```csharp
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
{
// 1. Load list + list_config
// 2. Resolve config (merge list_config + task overrides)
// 3. Create worktree/sandbox (unchanged)
// 4. Execute run (see RunOnceAsync below)
// 5. If failed and no prior retry: auto-retry
// 6. Final status update
}
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
{
// 1. Load task, last run (for session_id)
// 2. Mark task running
// 3. Execute run with --resume
// 4. Commit if success + worktree
// 5. Final status update
}
private async Task<RunResult> RunOnceAsync(
TaskEntity task, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct)
{
// 1. Create task_runs row (started_at = now)
// 2. Build log path: {task_id}_run{runNumber}.ndjson
// 3. Build CLI args via ClaudeArgsBuilder
// 4. Spawn ClaudeProcess
// 5. Stream lines to LogWriter + StreamAnalyzer + HubBroadcaster
// 6. Build RunResult from StreamAnalyzer
// 7. Update task_runs row (finished_at, metrics, result)
// 8. Update denormalized tasks fields
// 9. Return RunResult
}
```
### 6.3 ClaudeProcess changes
Simplified — receives pre-built args, no longer constructs its own:
```csharp
public async Task<RunResult> RunAsync(
string arguments, // pre-built by ClaudeArgsBuilder
string prompt, // written to stdin
string workingDirectory,
Func<string, Task> onStdoutLine,
CancellationToken ct)
```
The `StreamAnalyzer` instance is owned by the caller (TaskRunner), not ClaudeProcess. ClaudeProcess just feeds lines via the callback.
---
## 7. Repository Changes
### 7.1 New: TaskRunRepository
| Method | Description |
|--------|-------------|
| `AddAsync(TaskRunEntity)` | Insert new run |
| `UpdateAsync(TaskRunEntity)` | Update after completion |
| `GetByTaskIdAsync(string taskId)` | All runs for a task, ordered by run_number |
| `GetLatestByTaskIdAsync(string taskId)` | Most recent run (for session_id lookup) |
| `GetByIdAsync(string runId)` | Single run |
### 7.2 Extended: ListRepository
| Method | Description |
|--------|-------------|
| `GetConfigAsync(string listId)` | Returns `ListConfigEntity?` |
| `SetConfigAsync(ListConfigEntity)` | Upsert via INSERT OR REPLACE |
### 7.3 New models
```csharp
public sealed class TaskRunEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required int RunNumber { get; init; }
public string? SessionId { get; set; }
public required bool IsRetry { get; init; }
public required string Prompt { get; init; }
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? ErrorMarkdown { get; set; }
public int? ExitCode { get; set; }
public int? TurnCount { get; set; }
public int? TokensIn { get; set; }
public int? TokensOut { get; set; }
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
}
public sealed class ListConfigEntity
{
public required string ListId { get; init; }
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
}
```
---
## 8. SignalR Hub Changes
### 8.1 New server methods
| Method | Description |
|--------|-------------|
| `ContinueTask(string taskId, string followUpPrompt)` | Trigger follow-up run. Returns `string runId`. Throws if running or no session. |
| `GetAgents()` | Returns `List<AgentInfo>` from AgentFileService scan |
| `RefreshAgents()` | Re-scan agents directory |
### 8.2 Updated broadcasts
| Event | Change |
|-------|--------|
| `TaskStarted(slot, taskId, runId, runNumber, startedAt)` | Added `runId`, `runNumber` |
| `TaskFinished(slot, taskId, runId, status, finishedAt)` | Added `runId` |
| `TaskMessage(taskId, runId, ndjsonLine)` | Added `runId` |
| `RunCreated(taskId, runId, runNumber, isRetry)` | New — signals retry/continue started |
### 8.3 Unchanged
`Ping`, `GetActive`, `CancelTask`, `WakeQueue`, `WorktreeUpdated`, `TaskUpdated` — no changes.
---
## 9. File Structure (New/Changed)
```
src/ClaudeDo.Worker/
Runner/
ClaudeArgsBuilder.cs NEW — CLI argument construction
StreamAnalyzer.cs NEW — replaces MessageParser
StreamResult.cs NEW — accumulated stream metrics
RunResult.cs CHANGED — extended with tokens, turns, session_id
ClaudeProcess.cs CHANGED — simplified, takes pre-built args
TaskRunner.cs CHANGED — retry/continue logic, config resolution
MessageParser.cs DELETED — replaced by StreamAnalyzer
Services/
AgentFileService.cs NEW — filesystem agent management
src/ClaudeDo.Data/
Models/
TaskRunEntity.cs NEW
ListConfigEntity.cs NEW
AgentInfo.cs NEW — DTO (name, description, path)
Repositories/
TaskRunRepository.cs NEW
ListRepository.cs CHANGED — GetConfigAsync, SetConfigAsync
schema/
schema.sql CHANGED — list_config table, task_runs table, tasks columns
```
---
## 10. Testing Strategy
### 10.1 Unit tests (new)
| Test class | Covers |
|------------|--------|
| `ClaudeArgsBuilderTests` | Arg construction with all config combos, omitted flags for null values |
| `StreamAnalyzerTests` | Turn counting, token accumulation, result extraction, session_id, retry events, malformed input |
| `AgentFileServiceTests` | Scan, frontmatter parsing, read/write/delete, missing directory handling |
### 10.2 Unit tests (updated)
| Test class | Changes |
|------------|---------|
| `TaskRunnerTests` | New: auto-retry flow, continue flow, config resolution |
| `QueueServiceTests` | New: continue task routing |
### 10.3 Integration tests (new)
| Test class | Covers |
|------------|--------|
| `TaskRunRepositoryTests` | CRUD, ordering, latest-by-task queries |
| `ListRepositoryConfigTests` | GetConfig, SetConfig upsert behavior |
### 10.4 Existing tests (MessageParserTests)
Removed along with `MessageParser`. Equivalent coverage moves to `StreamAnalyzerTests`.

View File

@@ -46,6 +46,13 @@ CREATE TABLE IF NOT EXISTS task_tags (
PRIMARY KEY (task_id, tag_id) PRIMARY KEY (task_id, tag_id)
); );
CREATE TABLE IF NOT EXISTS list_config (
list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
model TEXT NULL,
system_prompt TEXT NULL,
agent_path TEXT NULL
);
CREATE TABLE IF NOT EXISTS worktrees ( CREATE TABLE IF NOT EXISTS worktrees (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE, task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
path TEXT NOT NULL, path TEXT NOT NULL,
@@ -57,6 +64,27 @@ CREATE TABLE IF NOT EXISTS worktrees (
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
); );
CREATE TABLE IF NOT EXISTS task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
run_number INTEGER NOT NULL,
session_id TEXT NULL,
is_retry INTEGER NOT NULL DEFAULT 0,
prompt TEXT NOT NULL,
result_markdown TEXT NULL,
structured_output TEXT NULL,
error_markdown TEXT NULL,
exit_code INTEGER NULL,
turn_count INTEGER NULL,
tokens_in INTEGER NULL,
tokens_out INTEGER NULL,
log_path TEXT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL
);
CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
-- Seed: minimal tag set (ignored if already present) -- Seed: minimal tag set (ignored if already present)
INSERT OR IGNORE INTO tags (name) VALUES ('agent'); INSERT OR IGNORE INTO tags (name) VALUES ('agent');
INSERT OR IGNORE INTO tags (name) VALUES ('manual'); INSERT OR IGNORE INTO tags (name) VALUES ('manual');

View File

@@ -0,0 +1,29 @@
# ClaudeDo.App
Desktop entry point for the ClaudeDo application. Configures DI, initializes the database, and launches the Avalonia window.
## Responsibility
- `Program.cs` — STA thread, DI container registration (repositories, services, viewmodels), schema init, Avalonia builder
- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
## Dependencies
- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
- CommunityToolkit.Mvvm 8.4.1
- Microsoft.Extensions.DependencyInjection 8.0.1
- Microsoft.AspNetCore.SignalR.Client 8.0.11
- Microsoft.Data.Sqlite 8.0.11
- Project references: ClaudeDo.Data, ClaudeDo.Ui
## DI Registration Pattern
- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
## Notes
- This project owns the composition root — all wiring happens here
- ViewLocator resolves `FooViewModel` -> `FooView` by replacing "ViewModel" with "View" in the type name
- AvaloniaUI diagnostics are conditionally included (DEBUG only)

View File

@@ -0,0 +1,41 @@
# ClaudeDo.Data
Shared data layer: models, repositories, SQLite infrastructure, and git operations.
## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **TagEntity** — Id (autoincrement), Name (unique)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
## Repositories
All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
- **ListRepository** — CRUD, tag junction management
- **TagRepository** — `GetOrCreateAsync` (idempotent)
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
## Infrastructure
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
## Schema
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual".
## Conventions
- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum
- Primary keys are `init`-only strings (GUIDs assigned at creation)
- Nullable fields use `DBNull.Value` checks
- All methods are async with CancellationToken where applicable

View File

@@ -0,0 +1,3 @@
namespace ClaudeDo.Data.Models;
public sealed record AgentInfo(string Name, string Description, string Path);

View File

@@ -0,0 +1,9 @@
namespace ClaudeDo.Data.Models;
public sealed class ListConfigEntity
{
public required string ListId { get; init; }
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
}

View File

@@ -23,4 +23,7 @@ public sealed class TaskEntity
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; } public DateTime? FinishedAt { get; set; }
public string CommitType { get; set; } = "chore"; public string CommitType { get; set; } = "chore";
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
} }

View File

@@ -0,0 +1,21 @@
namespace ClaudeDo.Data.Models;
public sealed class TaskRunEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required int RunNumber { get; init; }
public string? SessionId { get; set; }
public required bool IsRetry { get; init; }
public required string Prompt { get; init; }
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? ErrorMarkdown { get; set; }
public int? ExitCode { get; set; }
public int? TurnCount { get; set; }
public int? TokensIn { get; set; }
public int? TokensOut { get; set; }
public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
}

View File

@@ -113,6 +113,39 @@ public sealed class ListRepository
await cmd.ExecuteNonQueryAsync(ct); await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return new ListConfigEntity
{
ListId = reader.GetString(0),
Model = reader.IsDBNull(1) ? null : reader.GetString(1),
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
};
}
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
VALUES (@list_id, @model, @system_prompt, @agent_path)
""";
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
private static ListEntity ReadList(SqliteDataReader reader) => new() private static ListEntity ReadList(SqliteDataReader reader) => new()
{ {
Id = reader.GetString(0), Id = reader.GetString(0),

View File

@@ -42,9 +42,11 @@ public sealed class TaskRepository
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for, INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type) result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path)
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for, VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type) @result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
@model, @system_prompt, @agent_path)
"""; """;
BindTask(cmd, entity); BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct); await cmd.ExecuteNonQueryAsync(ct);
@@ -58,7 +60,8 @@ public sealed class TaskRepository
UPDATE tasks SET list_id = @list_id, title = @title, description = @description, UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
status = @status, scheduled_for = @scheduled_for, result = @result, status = @status, scheduled_for = @scheduled_for, result = @result,
log_path = @log_path, started_at = @started_at, log_path = @log_path, started_at = @started_at,
finished_at = @finished_at, commit_type = @commit_type finished_at = @finished_at, commit_type = @commit_type,
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id WHERE id = @id
"""; """;
BindTask(cmd, entity); BindTask(cmd, entity);
@@ -78,7 +81,7 @@ public sealed class TaskRepository
{ {
await using var conn = _factory.Open(); await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type FROM tasks WHERE id = @id"; cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId); cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct); await using var reader = await cmd.ExecuteReaderAsync(ct);
@@ -90,7 +93,7 @@ public sealed class TaskRepository
{ {
await using var conn = _factory.Open(); await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type FROM tasks WHERE list_id = @list_id ORDER BY created_at"; cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at";
cmd.Parameters.AddWithValue("@list_id", listId); cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct); await using var reader = await cmd.ExecuteReaderAsync(ct);
@@ -175,7 +178,8 @@ public sealed class TaskRepository
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for, SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for,
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type,
t.model, t.system_prompt, t.agent_path
FROM tasks t FROM tasks t
WHERE t.status = 'queued' WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now)
@@ -277,6 +281,9 @@ public sealed class TaskRepository
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value); cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType); cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
} }
private static TaskEntity ReadTask(SqliteDataReader r) => new() private static TaskEntity ReadTask(SqliteDataReader r) => new()
@@ -293,6 +300,9 @@ public sealed class TaskRepository
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)), StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)), FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11), CommitType = r.GetString(11),
Model = r.IsDBNull(12) ? null : r.GetString(12),
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
}; };
#endregion #endregion

View File

@@ -0,0 +1,139 @@
using System.Globalization;
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository
{
private readonly SqliteConnectionFactory _factory;
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
result_markdown, structured_output, error_markdown, exit_code,
turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
@result_markdown, @structured_output, @error_markdown, @exit_code,
@turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
""";
BindRun(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE task_runs SET session_id = @session_id,
result_markdown = @result_markdown,
structured_output = @structured_output,
error_markdown = @error_markdown,
exit_code = @exit_code,
turn_count = @turn_count,
tokens_in = @tokens_in,
tokens_out = @tokens_out,
finished_at = @finished_at
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
}
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TaskRunEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadRun(reader));
return result;
}
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
}
#region Helpers
private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
cmd.Parameters.AddWithValue("@prompt", e.Prompt);
cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
}
private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
RunNumber = r.GetInt32(2),
SessionId = r.IsDBNull(3) ? null : r.GetString(3),
IsRetry = r.GetInt32(4) != 0,
Prompt = r.GetString(5),
ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
LogPath = r.IsDBNull(13) ? null : r.GetString(13),
StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
};
#endregion
}

View File

@@ -26,6 +26,32 @@ public static class SchemaInitializer
cmd.CommandText = sql; cmd.CommandText = sql;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
tx.Commit(); tx.Commit();
ApplyMigrations(conn);
}
private static void ApplyMigrations(SqliteConnection conn)
{
string[] alterStatements =
[
"ALTER TABLE tasks ADD COLUMN model TEXT NULL",
"ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
"ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
];
foreach (var sql in alterStatements)
{
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
catch (SqliteException ex) when (ex.SqliteErrorCode == 1)
{
// Column already exists — safe to ignore.
}
}
} }
private static string LoadScript() private static string LoadScript()

49
src/ClaudeDo.Ui/CLAUDE.md Normal file
View File

@@ -0,0 +1,49 @@
# ClaudeDo.Ui
Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
## Pattern
MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties
- `[RelayCommand]` for commands (supports async and CanExecute)
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
## Views
- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
- **TaskEditorView** — Modal dialog for task create/edit
- **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display
All views use compiled bindings (`x:DataType`).
## ViewModels
- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
- **StatusBarViewModel** — connection state and active tasks
## Services
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
## Converters
- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
## Dialog Pattern
Editor dialogs use `TaskCompletionSource<bool>` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
## Notes
- Context menus are on both list items and task items
- Right-click selects the item before showing the context menu
- "Run Now" CanExecute re-evaluates when worker connection state changes

View File

@@ -0,0 +1,115 @@
using System.Text;
using System.Text.Json;
namespace ClaudeDo.Ui.Helpers;
public class StreamLineFormatter
{
private const int MaxLength = 50_000;
public string? FormatLine(string line)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(line);
}
catch (JsonException)
{
return line;
}
using (doc)
{
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp))
return null;
var type = typeProp.GetString();
switch (type)
{
case "stream_event":
return FormatStreamEvent(root);
case "result":
if (root.TryGetProperty("result", out var resultProp))
return $"\n--- Result ---\n{resultProp.GetString()}\n";
return null;
case "system":
if (root.TryGetProperty("subtype", out var subtypeProp) &&
subtypeProp.GetString() == "api_retry")
return "\n[Retrying API call...]\n";
return null;
default:
return null;
}
}
}
private static string? FormatStreamEvent(JsonElement root)
{
if (!root.TryGetProperty("event", out var ev))
return null;
if (!ev.TryGetProperty("type", out var evTypeProp))
return null;
var evType = evTypeProp.GetString();
switch (evType)
{
case "content_block_delta":
if (!ev.TryGetProperty("delta", out var delta))
return null;
if (!delta.TryGetProperty("type", out var deltaTypeProp))
return null;
var deltaType = deltaTypeProp.GetString();
if (deltaType == "text_delta")
{
return delta.TryGetProperty("text", out var textProp)
? textProp.GetString()
: null;
}
return null; // input_json_delta and others → skip
case "content_block_stop":
return "\n";
case "content_block_start":
if (!ev.TryGetProperty("content_block", out var cb))
return null;
if (cb.TryGetProperty("type", out var cbTypeProp) &&
cbTypeProp.GetString() == "tool_use" &&
cb.TryGetProperty("name", out var nameProp))
return $"\n[Tool: {nameProp.GetString()}]\n";
return null;
default:
return null; // message_start, message_delta, etc.
}
}
public string FormatFile(string filePath)
{
var sb = new StringBuilder();
foreach (var line in File.ReadLines(filePath))
{
var formatted = FormatLine(line);
if (formatted is not null)
sb.Append(formatted);
}
return Trim(sb.ToString());
}
public static string Trim(string text)
{
if (text.Length <= MaxLength) return text;
var trimStart = text.Length - MaxLength;
var newlineAfter = text.IndexOf('\n', trimStart);
if (newlineAfter >= 0 && newlineAfter < trimStart + 200)
trimStart = newlineAfter + 1;
return text[trimStart..];
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Avalonia.Threading; using Avalonia.Threading;
using ClaudeDo.Data.Models;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
@@ -42,6 +43,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public event Action<string, string>? TaskMessageEvent; public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent; public event Action<string>? TaskUpdatedEvent;
public event Action<string>? WorktreeUpdatedEvent; public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? RunNowRequestedEvent;
public WorkerClient(string signalRUrl) public WorkerClient(string signalRUrl)
{ {
@@ -162,6 +164,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
public async Task RunNowAsync(string taskId) public async Task RunNowAsync(string taskId)
{ {
RunNowRequestedEvent?.Invoke(taskId);
await _hub.InvokeAsync("RunNow", taskId); await _hub.InvokeAsync("RunNow", taskId);
} }
@@ -175,6 +178,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("WakeQueue"); await _hub.InvokeAsync("WakeQueue");
} }
public async Task<List<AgentInfo>> GetAgentsAsync()
{
try
{
var agents = await _hub.InvokeAsync<List<AgentInfo>>("GetAgents");
return agents ?? [];
}
catch
{
return [];
}
}
public async Task RefreshAgentsAsync()
{
await _hub.InvokeAsync("RefreshAgents");
}
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
try try
@@ -200,7 +221,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.DisposeAsync(); await _hub.DisposeAsync();
} }
// DTO for deserializing the GetActive response // DTOs for deserializing hub responses
private sealed class ActiveTaskDto private sealed class ActiveTaskDto
{ {
public string Slot { get; set; } = ""; public string Slot { get; set; } = "";

View File

@@ -1,6 +1,8 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
@@ -11,6 +13,11 @@ public partial class ListEditorViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = "chore"; [ObservableProperty] private string _defaultCommitType = "chore";
[ObservableProperty] private string _windowTitle = "New List"; [ObservableProperty] private string _windowTitle = "New List";
// Config fields
[ObservableProperty] private string _model = "Sonnet";
[ObservableProperty] private string? _systemPrompt;
[ObservableProperty] private AgentInfo? _selectedAgent;
private string? _editId; private string? _editId;
private DateTime _createdAt; private DateTime _createdAt;
private TaskCompletionSource<ListEntity?> _tcs = new(); private TaskCompletionSource<ListEntity?> _tcs = new();
@@ -20,6 +27,31 @@ public partial class ListEditorViewModel : ViewModelBase
public static string[] CommitTypes { get; } = public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"]; ["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] ModelDisplayNames { get; } = ["Sonnet", "Opus", "Haiku"];
private static readonly Dictionary<string, string> ModelToId = new()
{
["Sonnet"] = "claude-sonnet-4-6",
["Opus"] = "claude-opus-4-6",
["Haiku"] = "claude-haiku-4-5",
};
private static readonly Dictionary<string, string> IdToModel =
ModelToId.ToDictionary(kv => kv.Value, kv => kv.Key);
public static string ModelIdToDisplay(string? modelId) =>
modelId is not null && IdToModel.TryGetValue(modelId, out var display) ? display : "Sonnet";
public static string? ModelDisplayToId(string display) =>
ModelToId.TryGetValue(display, out var id) ? id : null;
public List<AgentInfo> AvailableAgents { get; set; } = [];
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
public void InitForCreate() public void InitForCreate()
{ {
_editId = null; _editId = null;
@@ -27,7 +59,7 @@ public partial class ListEditorViewModel : ViewModelBase
WindowTitle = "New List"; WindowTitle = "New List";
} }
public void InitForEdit(ListEntity entity) public void InitForEdit(ListEntity entity, ListConfigEntity? config)
{ {
_editId = entity.Id; _editId = entity.Id;
_createdAt = entity.CreatedAt; _createdAt = entity.CreatedAt;
@@ -35,6 +67,28 @@ public partial class ListEditorViewModel : ViewModelBase
WorkingDir = entity.WorkingDir; WorkingDir = entity.WorkingDir;
DefaultCommitType = entity.DefaultCommitType; DefaultCommitType = entity.DefaultCommitType;
WindowTitle = $"Edit List: {entity.Name}"; WindowTitle = $"Edit List: {entity.Name}";
if (config is not null)
{
Model = ModelIdToDisplay(config.Model);
SystemPrompt = config.SystemPrompt;
SelectedAgent = AvailableAgents.FirstOrDefault(a => a.Path == config.AgentPath);
}
}
public ListConfigEntity? BuildConfig(string listId)
{
var modelId = ModelDisplayToId(Model);
if (modelId is null && SystemPrompt is null && SelectedAgent is null)
return null;
return new ListConfigEntity
{
ListId = listId,
Model = modelId,
SystemPrompt = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt.Trim(),
AgentPath = SelectedAgent?.Path,
};
} }
[RelayCommand] [RelayCommand]
@@ -60,10 +114,11 @@ public partial class ListEditorViewModel : ViewModelBase
RequestClose?.Invoke(); RequestClose?.Invoke();
} }
/// <summary> public void OnWindowClosed()
/// Called by the view to await the editor result. {
/// Returns the entity to persist or null if cancelled. _tcs.TrySetResult(null);
/// </summary> }
public Task<ListEntity?> ShowAndWaitAsync() public Task<ListEntity?> ShowAndWaitAsync()
{ {
_tcs = new TaskCompletionSource<ListEntity?>(); _tcs = new TaskCompletionSource<ListEntity?>();

View File

@@ -78,10 +78,12 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task AddList() private async Task AddList()
{ {
var editor = _listEditorFactory(); var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(); editor.InitForCreate();
var window = new ListEditorView { DataContext = editor }; var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window); _ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync(); var entity = await editor.ShowAndWaitAsync();
@@ -90,6 +92,9 @@ public partial class MainWindowViewModel : ViewModelBase
try try
{ {
await _listRepo.AddAsync(entity); await _listRepo.AddAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity)); Lists.Add(new ListItemViewModel(entity));
} }
catch (Exception ex) catch (Exception ex)
@@ -105,11 +110,14 @@ public partial class MainWindowViewModel : ViewModelBase
var existing = await _listRepo.GetByIdAsync(SelectedList.Id); var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return; if (existing is null) return;
var config = await _listRepo.GetConfigAsync(existing.Id);
var editor = _listEditorFactory(); var editor = _listEditorFactory();
editor.InitForEdit(existing); await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(existing, config);
var window = new ListEditorView { DataContext = editor }; var window = new ListEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window); _ = ShowDialogAsync(window);
var entity = await editor.ShowAndWaitAsync(); var entity = await editor.ShowAndWaitAsync();
@@ -118,6 +126,9 @@ public partial class MainWindowViewModel : ViewModelBase
try try
{ {
await _listRepo.UpdateAsync(entity); await _listRepo.UpdateAsync(entity);
var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name; SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir; SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType; SelectedList.DefaultCommitType = entity.DefaultCommitType;

View File

@@ -1,8 +1,11 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -37,14 +40,14 @@ public partial class TaskDetailViewModel : ViewModelBase
[ObservableProperty] private string _worktreeState = ""; [ObservableProperty] private string _worktreeState = "";
// Live stream // Live stream
public ObservableCollection<string> LiveLines { get; } = new(); [ObservableProperty] private string _liveText = "";
private StreamLineFormatter _formatter = new();
public ObservableCollection<TagEntity> Tags { get; } = new(); public ObservableCollection<TagEntity> Tags { get; } = new();
[ObservableProperty] private string _newTagInput = ""; [ObservableProperty] private string _newTagInput = "";
private string? _taskId; private string? _taskId;
private string? _listId; private string? _listId;
private bool _isLoading; private bool _isLoading;
private const int MaxLiveLines = 500;
public event Action<string>? TaskChanged; public event Action<string>? TaskChanged;
@@ -61,12 +64,15 @@ public partial class TaskDetailViewModel : ViewModelBase
worker.TaskMessageEvent += OnTaskMessage; worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated; worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
worker.TaskUpdatedEvent += OnTaskUpdated; worker.TaskUpdatedEvent += OnTaskUpdated;
worker.RunNowRequestedEvent += OnRunNowRequested;
worker.TaskStartedEvent += OnTaskStarted;
} }
public async Task LoadAsync(string taskId) public async Task LoadAsync(string taskId)
{ {
_taskId = taskId; _taskId = taskId;
LiveLines.Clear(); LiveText = "";
_formatter = new StreamLineFormatter();
var task = await _taskRepo.GetByIdAsync(taskId); var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return; if (task is null) return;
@@ -79,6 +85,13 @@ public partial class TaskDetailViewModel : ViewModelBase
Description = task.Description; Description = task.Description;
Result = task.Result; Result = task.Result;
LogPath = task.LogPath; LogPath = task.LogPath;
if (task.LogPath is not null
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
&& File.Exists(task.LogPath))
{
_formatter = new StreamLineFormatter();
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
}
StatusText = task.Status.ToString().ToLowerInvariant(); StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString(); StatusChoice = task.Status.ToString();
CommitType = task.CommitType; CommitType = task.CommitType;
@@ -152,7 +165,8 @@ public partial class TaskDetailViewModel : ViewModelBase
LogPath = null; LogPath = null;
StatusText = ""; StatusText = "";
HasWorktree = false; HasWorktree = false;
LiveLines.Clear(); LiveText = "";
_formatter = new StreamLineFormatter();
Tags.Clear(); Tags.Clear();
NewTagInput = ""; NewTagInput = "";
StatusChoice = "Manual"; StatusChoice = "Manual";
@@ -259,9 +273,27 @@ public partial class TaskDetailViewModel : ViewModelBase
private void OnTaskMessage(string taskId, string line) private void OnTaskMessage(string taskId, string line)
{ {
if (taskId != _taskId) return; if (taskId != _taskId) return;
if (LiveLines.Count >= MaxLiveLines) var formatted = _formatter.FormatLine(line);
LiveLines.RemoveAt(0); if (formatted is not null)
LiveLines.Add(line); {
LiveText += formatted;
if (LiveText.Length > 50_000)
LiveText = StreamLineFormatter.Trim(LiveText);
}
}
private void OnRunNowRequested(string taskId)
{
if (taskId != _taskId) return;
StatusText = "starting...";
LiveText = "";
_formatter = new StreamLineFormatter();
}
private void OnTaskStarted(string slot, string taskId, DateTime startedAt)
{
if (taskId != _taskId) return;
StatusText = "running";
} }
private async void OnWorktreeUpdated(string taskId) private async void OnWorktreeUpdated(string taskId)

View File

@@ -1,4 +1,5 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -13,6 +14,10 @@ public partial class TaskEditorViewModel : ViewModelBase
[ObservableProperty] private string _statusChoice = "manual"; [ObservableProperty] private string _statusChoice = "manual";
[ObservableProperty] private string _tagsInput = ""; [ObservableProperty] private string _tagsInput = "";
[ObservableProperty] private string _windowTitle = "New Task"; [ObservableProperty] private string _windowTitle = "New Task";
[ObservableProperty] private string _modelChoice = "(list default)";
[ObservableProperty] private string? _systemPromptOverride;
[ObservableProperty] private AgentInfo? _selectedAgent;
public List<AgentInfo> AvailableAgents { get; set; } = [];
private string? _editId; private string? _editId;
private string _listId = ""; private string _listId = "";
@@ -21,12 +26,19 @@ public partial class TaskEditorViewModel : ViewModelBase
public event Action? RequestClose; public event Action? RequestClose;
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
public static string[] CommitTypes { get; } = public static string[] CommitTypes { get; } =
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"]; ["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
public static string[] StatusChoices { get; } = public static string[] StatusChoices { get; } =
["manual", "queued"]; ["manual", "queued"];
public async Task LoadAgentsAsync(WorkerClient worker)
{
AvailableAgents = await worker.GetAgentsAsync();
}
public IReadOnlyList<string> SelectedTagNames => public IReadOnlyList<string> SelectedTagNames =>
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct() .Distinct()
@@ -56,6 +68,13 @@ public partial class TaskEditorViewModel : ViewModelBase
_ => entity.Status.ToString().ToLowerInvariant(), _ => entity.Status.ToString().ToLowerInvariant(),
}; };
TagsInput = string.Join(", ", taskTags.Select(t => t.Name)); TagsInput = string.Join(", ", taskTags.Select(t => t.Name));
ModelChoice = entity.Model is not null
? ListEditorViewModel.ModelIdToDisplay(entity.Model)
: "(list default)";
SystemPromptOverride = entity.SystemPrompt;
SelectedAgent = entity.AgentPath is not null
? AvailableAgents.FirstOrDefault(a => a.Path == entity.AgentPath)
: null;
WindowTitle = $"Edit Task: {entity.Title}"; WindowTitle = $"Edit Task: {entity.Title}";
} }
@@ -78,6 +97,11 @@ public partial class TaskEditorViewModel : ViewModelBase
CommitType = CommitType, CommitType = CommitType,
CreatedAt = _createdAt, CreatedAt = _createdAt,
}; };
entity.Model = ModelChoice != "(list default)"
? ListEditorViewModel.ModelDisplayToId(ModelChoice)
: null;
entity.SystemPrompt = string.IsNullOrWhiteSpace(SystemPromptOverride) ? null : SystemPromptOverride.Trim();
entity.AgentPath = SelectedAgent?.Path;
_tcs.TrySetResult(entity); _tcs.TrySetResult(entity);
RequestClose?.Invoke(); RequestClose?.Invoke();
} }
@@ -89,6 +113,11 @@ public partial class TaskEditorViewModel : ViewModelBase
RequestClose?.Invoke(); RequestClose?.Invoke();
} }
public void OnWindowClosed()
{
_tcs.TrySetResult(null);
}
public Task<TaskEntity?> ShowAndWaitAsync() public Task<TaskEntity?> ShowAndWaitAsync()
{ {
_tcs = new TaskCompletionSource<TaskEntity?>(); _tcs = new TaskCompletionSource<TaskEntity?>();

View File

@@ -14,6 +14,7 @@ public partial class TaskItemViewModel : ViewModelBase
[ObservableProperty] private string _commitType; [ObservableProperty] private string _commitType;
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
[ObservableProperty] private TaskStatus _status; [ObservableProperty] private TaskStatus _status;
[ObservableProperty] private bool _isStarting;
public string Id { get; } public string Id { get; }
public string ListId { get; } public string ListId { get; }
@@ -66,6 +67,7 @@ public partial class TaskItemViewModel : ViewModelBase
RunNowCommand.NotifyCanExecuteChanged(); RunNowCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
IsStarting = false;
OnPropertyChanged(nameof(CanToggleDone)); OnPropertyChanged(nameof(CanToggleDone));
OnPropertyChanged(nameof(TitleDecorations)); OnPropertyChanged(nameof(TitleDecorations));
OnPropertyChanged(nameof(TitleForeground)); OnPropertyChanged(nameof(TitleForeground));
@@ -73,6 +75,19 @@ public partial class TaskItemViewModel : ViewModelBase
ToggleDoneCommand.NotifyCanExecuteChanged(); ToggleDoneCommand.NotifyCanExecuteChanged();
} }
public void SetStarting()
{
IsStarting = true;
StatusText = "starting...";
RunNowCommand.NotifyCanExecuteChanged();
}
public void ClearStarting()
{
IsStarting = false;
RunNowCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanRunNow))] [RelayCommand(CanExecute = nameof(CanRunNow))]
private async Task RunNowAsync() private async Task RunNowAsync()
{ {
@@ -81,7 +96,7 @@ public partial class TaskItemViewModel : ViewModelBase
} }
private bool CanRunNow() => private bool CanRunNow() =>
_canRunNow() && Status == TaskStatus.Queued; _canRunNow() && Status != TaskStatus.Running && !IsStarting;
[RelayCommand(CanExecute = nameof(CanToggleDone))] [RelayCommand(CanExecute = nameof(CanToggleDone))]
private async Task ToggleDone() private async Task ToggleDone()

View File

@@ -55,6 +55,18 @@ public partial class TaskListViewModel : ViewModelBase
t.RunNowCommand.NotifyCanExecuteChanged(); t.RunNowCommand.NotifyCanExecuteChanged();
}); });
}; };
worker.RunNowRequestedEvent += taskId =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.SetStarting();
};
worker.TaskStartedEvent += (_, taskId, _) =>
{
var item = Tasks.FirstOrDefault(t => t.Id == taskId);
item?.ClearStarting();
};
} }
public async Task LoadAsync(string? listId) public async Task LoadAsync(string? listId)
@@ -134,10 +146,12 @@ public partial class TaskListViewModel : ViewModelBase
var defaultCommitType = list?.DefaultCommitType ?? "chore"; var defaultCommitType = list?.DefaultCommitType ?? "chore";
var editor = _editorFactory(); var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForCreate(CurrentListId, defaultCommitType); editor.InitForCreate(CurrentListId, defaultCommitType);
var window = new TaskEditorView { DataContext = editor }; var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window); _ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync(); var saved = await editor.ShowAndWaitAsync();
@@ -179,10 +193,12 @@ public partial class TaskListViewModel : ViewModelBase
var taskTags = await _taskRepo.GetTagsAsync(entity.Id); var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
var editor = _editorFactory(); var editor = _editorFactory();
await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(entity, taskTags); editor.InitForEdit(entity, taskTags);
var window = new TaskEditorView { DataContext = editor }; var window = new TaskEditorView { DataContext = editor };
editor.RequestClose += () => window.Close(); editor.RequestClose += () => window.Close();
window.Closed += (_, _) => editor.OnWindowClosed();
_ = ShowDialogAsync(window); _ = ShowDialogAsync(window);
var saved = await editor.ShowAndWaitAsync(); var saved = await editor.ShowAndWaitAsync();

View File

@@ -1,27 +1,61 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:svc="using:ClaudeDo.Data.Models"
x:Class="ClaudeDo.Ui.Views.ListEditorView" x:Class="ClaudeDo.Ui.Views.ListEditorView"
x:DataType="vm:ListEditorViewModel" x:DataType="vm:ListEditorViewModel"
Title="{Binding WindowTitle}" Title="{Binding WindowTitle}"
Width="450" Height="280" Width="450" Height="480"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
CanResize="False"> CanResize="False"
Background="{StaticResource WindowBgBrush}">
<StackPanel Margin="16" Spacing="10"> <StackPanel Margin="16" Spacing="10">
<TextBlock Text="Name" FontWeight="SemiBold"/> <TextBlock Text="Name" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Name}" PlaceholderText="List name..."/> <TextBox Text="{Binding Name}" PlaceholderText="List name..."/>
<TextBlock Text="Working Directory" FontWeight="SemiBold"/> <TextBlock Text="Working Directory" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding WorkingDir}" PlaceholderText="(optional) Absolute path to git repo"/> <DockPanel>
<!-- TODO: folder picker button using IStorageProvider --> <Button DockPanel.Dock="Right" Content="Browse..." Click="OnBrowseFolder" Margin="8,0,0,0" VerticalAlignment="Center"/>
<TextBox Text="{Binding WorkingDir}" PlaceholderText="(optional) Absolute path to git repo"/>
</DockPanel>
<TextBlock Text="Default Commit Type" FontWeight="SemiBold"/> <TextBlock Text="Default Commit Type" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding CommitTypes}" <ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding DefaultCommitType}" SelectedItem="{Binding DefaultCommitType}"
MinWidth="150"/> MinWidth="150"/>
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Agent Config" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Model" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelDisplayNames}"
SelectedItem="{Binding Model}"
MinWidth="150"/>
<TextBlock Text="System Prompt" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPrompt}"
PlaceholderText="(optional) Additional system instructions..."
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>
<TextBlock Text="Agent File" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="svc:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"/> <Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/> <Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@@ -1,4 +1,9 @@
using System;
using System.IO;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views; namespace ClaudeDo.Ui.Views;
@@ -8,4 +13,28 @@ public partial class ListEditorView : Window
{ {
InitializeComponent(); InitializeComponent();
} }
private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
{
var vm = DataContext as ListEditorViewModel;
var startPath = !string.IsNullOrWhiteSpace(vm?.WorkingDir) && Directory.Exists(vm.WorkingDir)
? vm.WorkingDir
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var startLocation = await StorageProvider.TryGetFolderFromPathAsync(new Uri(startPath));
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Select Working Directory",
SuggestedStartLocation = startLocation,
AllowMultiple = false,
});
if (result.Count > 0)
{
var path = result[0].TryGetLocalPath();
if (path is not null && vm is not null)
vm.WorkingDir = path;
}
}
} }

View File

@@ -106,20 +106,19 @@
<TextBlock Text="Live Output" FontWeight="SemiBold" FontSize="12" <TextBlock Text="Live Output" FontWeight="SemiBold" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/> Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
<Border BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1" <TextBox x:Name="LiveOutputBox"
CornerRadius="6" Padding="6" MaxHeight="200"> Text="{Binding LiveText, Mode=OneWay}"
<ScrollViewer> IsReadOnly="True"
<ItemsControl ItemsSource="{Binding LiveLines}"> AcceptsReturn="True"
<ItemsControl.ItemTemplate> TextWrapping="NoWrap"
<DataTemplate> FontFamily="Consolas,Courier New,monospace"
<TextBlock Text="{Binding}" FontFamily="Consolas,Courier New,monospace" FontSize="11"
FontSize="11" TextWrapping="NoWrap" MaxHeight="300"
Foreground="{StaticResource TextPrimaryBrush}"/> Foreground="{StaticResource TextPrimaryBrush}"
</DataTemplate> BorderBrush="{StaticResource BorderSubtleBrush}"
</ItemsControl.ItemTemplate> BorderThickness="1"
</ItemsControl> CornerRadius="6"
</ScrollViewer> Padding="6"/>
</Border>
<Border IsVisible="{Binding HasWorktree}" BorderBrush="{StaticResource AccentBrush}" <Border IsVisible="{Binding HasWorktree}" BorderBrush="{StaticResource AccentBrush}"
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,8,0,0"> BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,8,0,0">

View File

@@ -1,3 +1,4 @@
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@@ -31,4 +32,28 @@ public partial class TaskDetailView : UserControl
{ {
this.FindControl<TextBox>("TitleBox")?.Focus(); this.FindControl<TextBox>("TitleBox")?.Focus();
} }
private TaskDetailViewModel? _previousVm;
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (_previousVm is not null)
_previousVm.PropertyChanged -= OnViewModelPropertyChanged;
_previousVm = DataContext as TaskDetailViewModel;
if (_previousVm is not null)
_previousVm.PropertyChanged += OnViewModelPropertyChanged;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskDetailViewModel.LiveText))
{
var box = this.FindControl<TextBox>("LiveOutputBox");
if (box is not null)
{
box.CaretIndex = box.Text?.Length ?? 0;
}
}
}
} }

View File

@@ -1,40 +1,73 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels" xmlns:vm="using:ClaudeDo.Ui.ViewModels"
xmlns:models="using:ClaudeDo.Data.Models"
x:Class="ClaudeDo.Ui.Views.TaskEditorView" x:Class="ClaudeDo.Ui.Views.TaskEditorView"
x:DataType="vm:TaskEditorViewModel" x:DataType="vm:TaskEditorViewModel"
Title="{Binding WindowTitle}" Title="{Binding WindowTitle}"
Width="500" Height="420" Width="500" Height="600"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
CanResize="False"> CanResize="False"
Background="{StaticResource WindowBgBrush}">
<StackPanel Margin="16" Spacing="10"> <StackPanel Margin="16" Spacing="10">
<TextBlock Text="Title" FontWeight="SemiBold"/> <TextBlock Text="Title" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Title}" PlaceholderText="Task title..."/> <TextBox Text="{Binding Title}" PlaceholderText="Task title..."/>
<TextBlock Text="Description" FontWeight="SemiBold"/> <TextBlock Text="Description" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding Description}" PlaceholderText="(optional)" AcceptsReturn="True" <TextBox Text="{Binding Description}" PlaceholderText="(optional)" AcceptsReturn="True"
TextWrapping="Wrap" MinHeight="80"/> TextWrapping="Wrap" MinHeight="80"/>
<Grid ColumnDefinitions="*,16,*"> <Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Status" FontWeight="SemiBold"/> <TextBlock Text="Status" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding StatusChoices}" <ComboBox ItemsSource="{Binding StatusChoices}"
SelectedItem="{Binding StatusChoice}" SelectedItem="{Binding StatusChoice}"
MinWidth="120"/> MinWidth="120"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="4"> <StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Commit Type" FontWeight="SemiBold"/> <TextBlock Text="Commit Type" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding CommitTypes}" <ComboBox ItemsSource="{Binding CommitTypes}"
SelectedItem="{Binding CommitType}" SelectedItem="{Binding CommitType}"
MinWidth="120"/> MinWidth="120"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold"/> <TextBlock Text="Tags (comma-separated)" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/> <TextBox Text="{Binding TagsInput}" PlaceholderText="agent, manual, code, ..."/>
<!-- Divider -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,6,0,2"/>
<TextBlock Text="Agent Config (overrides)" FontWeight="Bold" FontSize="13"
Foreground="{StaticResource TextPrimaryBrush}"/>
<TextBlock Text="Model" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding ModelChoices}"
SelectedItem="{Binding ModelChoice}"
MinWidth="150"/>
<TextBlock Text="System Prompt" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<TextBox Text="{Binding SystemPromptOverride}"
PlaceholderText="(inherits from list)"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="50"/>
<TextBlock Text="Agent File" FontWeight="SemiBold"
Foreground="{StaticResource TextSecondaryBrush}"/>
<ComboBox ItemsSource="{Binding AvailableAgents}"
SelectedItem="{Binding SelectedAgent}"
MinWidth="150">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:AgentInfo">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"/> <Button Content="Save" Command="{Binding SaveCommand}" IsDefault="True" MinWidth="80"
Background="{StaticResource AccentBrush}" Foreground="{StaticResource TextPrimaryBrush}"/>
<Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/> <Button Content="Cancel" Command="{Binding CancelCommand}" IsCancel="True" MinWidth="80"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@@ -70,6 +70,10 @@
Fill="{StaticResource StatusOrangeBrush}" Fill="{StaticResource StatusOrangeBrush}"
IsVisible="{Binding IsRunning}" IsVisible="{Binding IsRunning}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Starting dot -->
<Ellipse Width="8" Height="8" Fill="#FFD700"
IsVisible="{Binding IsStarting}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Panel> </Panel>
</Border> </Border>

View File

@@ -0,0 +1,67 @@
# ClaudeDo.Worker
ASP.NET Core hosted service that executes tasks via Claude CLI in isolated environments.
## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **QueueService** — `BackgroundService` with two execution slots:
- Queue slot: FIFO sequential processing of "agent"-tagged queued tasks
- Override slot: immediate execution via `RunNow(taskId)`
- Wake signaling via `SemaphoreSlim`, backstop timer (30s default)
- **StaleTaskRecovery** — startup-only service, flips orphaned "running" tasks to "failed"
## Task Execution Pipeline
`TaskRunner` orchestrates:
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
2. Create worktree (if `WorkingDir` set) or sandbox directory
3. Mark task "running", broadcast `TaskStarted`
4. Build CLI args via `ClaudeArgsBuilder`; invoke `ClaudeProcess` with task prompt
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
6. On success: auto-commit changes (worktree only), store run record, mark "done"
7. On failure: retry once if session ID available (`--resume`), then mark "failed"
## Key Components
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`. Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
- **LogWriter** — async StreamWriter wrapper, auto-creates parent dirs
## Execution History
Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`:
- Fields: `session_id`, input/output/cache token counts, turn count, `result` text, structured output JSON
- Enables auto-retry on failure (resume last session) and multi-turn follow-up via `ContinueAsync`
## Multi-Turn / Continue
`TaskRunner.ContinueAsync` sends a follow-up prompt to an existing Claude session using `--resume <session_id>` with the stored session ID from the last run.
## SignalR Hub
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `GetAgents()`, `RefreshAgents()`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`
## Config
Loaded from `~/.todo-app/worker.config.json`:
- `db_path`, `sandbox_root`, `log_root`
- `worktree_root_strategy` ("sibling" | "central"), `central_worktree_root`
- `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821)
- `claude_bin` (path to claude CLI)
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually.
## Notes
- The worker runs standalone — start it separately from the UI
- Only listens on loopback (127.0.0.1)
- ClaudeProcess uses `--dangerously-skip-permissions` — tasks run with full filesystem access
- Worktree branches follow `claudedo/{id}` naming convention

View File

@@ -22,4 +22,7 @@ public sealed class HubBroadcaster
public Task TaskUpdated(string taskId) => public Task TaskUpdated(string taskId) =>
_hub.Clients.All.SendAsync("TaskUpdated", taskId); _hub.Clients.All.SendAsync("TaskUpdated", taskId);
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
} }

View File

@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Services; using ClaudeDo.Worker.Services;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
@@ -10,8 +11,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly AgentFileService _agentService;
public WorkerHub(QueueService queue) => _queue = queue; public WorkerHub(QueueService queue, AgentFileService agentService)
{
_queue = queue;
_agentService = agentService;
}
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
@@ -38,7 +44,27 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
} }
} }
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
{
try
{
return await _queue.ContinueTask(taskId, followUpPrompt);
}
catch (InvalidOperationException ex)
{
throw new HubException(ex.Message);
}
catch (KeyNotFoundException)
{
throw new HubException("task not found");
}
}
public bool CancelTask(string taskId) => _queue.CancelTask(taskId); public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
public void WakeQueue() => _queue.WakeQueue(); public void WakeQueue() => _queue.WakeQueue();
public async Task<List<AgentInfo>> GetAgents() => await _agentService.ScanAsync();
public async Task RefreshAgents() => await _agentService.ScanAsync();
} }

View File

@@ -20,6 +20,7 @@ builder.Services.AddSingleton<TagRepository>();
builder.Services.AddSingleton<ListRepository>(); builder.Services.AddSingleton<ListRepository>();
builder.Services.AddSingleton<TaskRepository>(); builder.Services.AddSingleton<TaskRepository>();
builder.Services.AddSingleton<WorktreeRepository>(); builder.Services.AddSingleton<WorktreeRepository>();
builder.Services.AddSingleton<TaskRunRepository>();
builder.Services.AddHostedService<StaleTaskRecovery>(); builder.Services.AddHostedService<StaleTaskRecovery>();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -28,8 +29,14 @@ builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
builder.Services.AddSingleton<HubBroadcaster>(); builder.Services.AddSingleton<HubBroadcaster>();
builder.Services.AddSingleton<GitService>(); builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>(); builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunner>(); builder.Services.AddSingleton<TaskRunner>();
// Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
Directory.CreateDirectory(agentsDir);
builder.Services.AddSingleton(new AgentFileService(agentsDir));
// QueueService: singleton + hosted service (same instance). // QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton<QueueService>(); builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());

View File

@@ -0,0 +1,65 @@
using System.Text.Json;
namespace ClaudeDo.Worker.Runner;
public sealed record ClaudeRunConfig(
string? Model,
string? SystemPrompt,
string? AgentPath,
string? ResumeSessionId
);
public sealed class ClaudeArgsBuilder
{
private static readonly string ResultSchema = JsonSerializer.Serialize(new
{
type = "object",
properties = new
{
summary = new { type = "string" },
files_changed = new { type = "array", items = new { type = "string" } },
commit_type = new { type = "string" },
},
required = new[] { "summary" },
});
public string Build(ClaudeRunConfig config)
{
var args = new List<string>
{
"-p",
"--output-format stream-json",
"--verbose",
"--dangerously-skip-permissions",
};
if (config.Model is not null)
args.Add($"--model {config.Model}");
if (config.SystemPrompt is not null)
args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
if (config.AgentPath is not null)
{
var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
args.Add($"--agents {Escape(agentJson)}");
}
args.Add($"--json-schema {Escape(ResultSchema)}");
if (config.ResumeSessionId is not null)
args.Add($"--resume {config.ResumeSessionId}");
return string.Join(" ", args);
}
private static string Escape(string value)
{
if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
{
var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
return $"\"{escaped}\"";
}
return value;
}
}

View File

@@ -16,17 +16,16 @@ public sealed class ClaudeProcess : IClaudeProcess
} }
public async Task<RunResult> RunAsync( public async Task<RunResult> RunAsync(
string arguments,
string prompt, string prompt,
string workingDirectory, string workingDirectory,
string logPath,
string taskId,
Func<string, Task> onStdoutLine, Func<string, Task> onStdoutLine,
CancellationToken ct) CancellationToken ct)
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = _cfg.ClaudeBin, FileName = _cfg.ClaudeBin,
Arguments = "-p --output-format stream-json --verbose --dangerously-skip-permissions", Arguments = arguments,
WorkingDirectory = workingDirectory, WorkingDirectory = workingDirectory,
RedirectStandardInput = true, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
@@ -40,30 +39,25 @@ public sealed class ClaudeProcess : IClaudeProcess
using var process = new Process { StartInfo = psi }; using var process = new Process { StartInfo = psi };
process.Start(); process.Start();
// Write prompt to stdin, then close.
await process.StandardInput.WriteAsync(prompt); await process.StandardInput.WriteAsync(prompt);
process.StandardInput.Close(); process.StandardInput.Close();
string? resultMarkdown = null; var analyzer = new StreamAnalyzer();
var lastStderr = new StringBuilder(); var lastStderr = new StringBuilder();
// Register cancellation to kill the process tree.
await using var ctr = ct.Register(() => await using var ctr = ct.Register(() =>
{ {
try { process.Kill(entireProcessTree: true); } try { process.Kill(entireProcessTree: true); }
catch { /* already exited */ } catch { /* already exited */ }
}); });
// Read stdout and stderr concurrently.
var stdoutTask = Task.Run(async () => var stdoutTask = Task.Run(async () =>
{ {
while (await process.StandardOutput.ReadLineAsync(ct) is { } line) while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
{ {
if (string.IsNullOrEmpty(line)) continue; if (string.IsNullOrEmpty(line)) continue;
await onStdoutLine(line); await onStdoutLine(line);
analyzer.ProcessLine(line);
if (MessageParser.TryExtractResult(line, out var res))
resultMarkdown = res;
} }
}, ct); }, ct);
@@ -81,16 +75,34 @@ public sealed class ClaudeProcess : IClaudeProcess
await process.WaitForExitAsync(ct); await process.WaitForExitAsync(ct);
var exitCode = process.ExitCode; var exitCode = process.ExitCode;
var streamResult = analyzer.GetResult();
if (exitCode == 0 && resultMarkdown is not null) if (exitCode == 0 && streamResult.ResultMarkdown is not null)
{ {
return new RunResult { ExitCode = exitCode, ResultMarkdown = resultMarkdown }; return new RunResult
{
ExitCode = exitCode,
ResultMarkdown = streamResult.ResultMarkdown,
StructuredOutputJson = streamResult.StructuredOutputJson,
SessionId = streamResult.SessionId,
TurnCount = streamResult.TurnCount,
TokensIn = streamResult.TokensIn,
TokensOut = streamResult.TokensOut,
};
} }
var error = lastStderr.Length > 0 var error = lastStderr.Length > 0
? lastStderr.ToString().Trim() ? lastStderr.ToString().Trim()
: $"Claude exited with code {exitCode} and no result."; : $"Claude exited with code {exitCode} and no result.";
return new RunResult { ExitCode = exitCode, ErrorMarkdown = error }; return new RunResult
{
ExitCode = exitCode,
ErrorMarkdown = error,
SessionId = streamResult.SessionId,
TurnCount = streamResult.TurnCount,
TokensIn = streamResult.TokensIn,
TokensOut = streamResult.TokensOut,
};
} }
} }

View File

@@ -3,10 +3,9 @@ namespace ClaudeDo.Worker.Runner;
public interface IClaudeProcess public interface IClaudeProcess
{ {
Task<RunResult> RunAsync( Task<RunResult> RunAsync(
string arguments,
string prompt, string prompt,
string workingDirectory, string workingDirectory,
string logPath,
string taskId,
Func<string, Task> onStdoutLine, Func<string, Task> onStdoutLine,
CancellationToken ct); CancellationToken ct);
} }

View File

@@ -1,33 +0,0 @@
using System.Text.Json;
namespace ClaudeDo.Worker.Runner;
public static class MessageParser
{
public static bool TryExtractResult(string ndjsonLine, out string? result)
{
result = null;
if (string.IsNullOrWhiteSpace(ndjsonLine))
return false;
try
{
using var doc = JsonDocument.Parse(ndjsonLine);
var root = doc.RootElement;
if (root.TryGetProperty("type", out var typeProp) &&
typeProp.GetString() == "result" &&
root.TryGetProperty("result", out var resultProp))
{
result = resultProp.GetString();
return true;
}
}
catch (JsonException)
{
// Malformed JSON — not a result line.
}
return false;
}
}

View File

@@ -5,6 +5,11 @@ public sealed class RunResult
public required int ExitCode { get; init; } public required int ExitCode { get; init; }
public string? ResultMarkdown { get; init; } public string? ResultMarkdown { get; init; }
public string? ErrorMarkdown { get; init; } public string? ErrorMarkdown { get; init; }
public string? StructuredOutputJson { get; init; }
public string? SessionId { get; init; }
public int TurnCount { get; init; }
public int TokensIn { get; init; }
public int TokensOut { get; init; }
public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null; public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
} }

View File

@@ -0,0 +1,79 @@
using System.Text.Json;
namespace ClaudeDo.Worker.Runner;
public sealed class StreamAnalyzer
{
private string? _resultMarkdown;
private string? _structuredOutputJson;
private string? _sessionId;
private int _turnCount;
private int _tokensIn;
private int _tokensOut;
private int _apiRetryCount;
public void ProcessLine(string ndjsonLine)
{
if (string.IsNullOrWhiteSpace(ndjsonLine)) return;
try
{
using var doc = JsonDocument.Parse(ndjsonLine);
var root = doc.RootElement;
if (!root.TryGetProperty("type", out var typeProp)) return;
var type = typeProp.GetString();
switch (type)
{
case "result":
if (root.TryGetProperty("result", out var resultProp))
_resultMarkdown = resultProp.GetString();
if (root.TryGetProperty("structured_output", out var structuredProp))
_structuredOutputJson = structuredProp.ToString();
if (root.TryGetProperty("session_id", out var sessionProp))
_sessionId = sessionProp.GetString();
break;
case "assistant":
_turnCount++;
break;
case "system":
if (root.TryGetProperty("subtype", out var subtypeProp) &&
subtypeProp.GetString() == "api_retry")
_apiRetryCount++;
break;
case "stream_event":
TryAccumulateUsage(root);
break;
}
}
catch (JsonException) { /* Malformed JSON — skip */ }
}
public StreamResult GetResult() => new()
{
ResultMarkdown = _resultMarkdown,
StructuredOutputJson = _structuredOutputJson,
SessionId = _sessionId,
TurnCount = _turnCount,
TokensIn = _tokensIn,
TokensOut = _tokensOut,
ApiRetryCount = _apiRetryCount,
};
private void TryAccumulateUsage(JsonElement root)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
if (eventProp.TryGetProperty("message", out var msgProp) &&
msgProp.TryGetProperty("usage", out var usageProp))
{
if (usageProp.TryGetProperty("input_tokens", out var inp))
_tokensIn += inp.GetInt32();
if (usageProp.TryGetProperty("output_tokens", out var outp))
_tokensOut += outp.GetInt32();
}
}
}

View File

@@ -0,0 +1,12 @@
namespace ClaudeDo.Worker.Runner;
public sealed class StreamResult
{
public string? ResultMarkdown { get; set; }
public string? StructuredOutputJson { get; set; }
public string? SessionId { get; set; }
public int TurnCount { get; set; }
public int TokensIn { get; set; }
public int TokensOut { get; set; }
public int ApiRetryCount { get; set; }
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
@@ -8,31 +9,40 @@ public sealed class TaskRunner
{ {
private readonly IClaudeProcess _claude; private readonly IClaudeProcess _claude;
private readonly TaskRepository _taskRepo; private readonly TaskRepository _taskRepo;
private readonly TaskRunRepository _runRepo;
private readonly ListRepository _listRepo; private readonly ListRepository _listRepo;
private readonly WorktreeRepository _wtRepo;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager; private readonly WorktreeManager _wtManager;
private readonly ClaudeArgsBuilder _argsBuilder;
private readonly WorkerConfig _cfg; private readonly WorkerConfig _cfg;
private readonly ILogger<TaskRunner> _logger; private readonly ILogger<TaskRunner> _logger;
public TaskRunner( public TaskRunner(
IClaudeProcess claude, IClaudeProcess claude,
TaskRepository taskRepo, TaskRepository taskRepo,
TaskRunRepository runRepo,
ListRepository listRepo, ListRepository listRepo,
WorktreeRepository wtRepo,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
WorktreeManager wtManager, WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder,
WorkerConfig cfg, WorkerConfig cfg,
ILogger<TaskRunner> logger) ILogger<TaskRunner> logger)
{ {
_claude = claude; _claude = claude;
_taskRepo = taskRepo; _taskRepo = taskRepo;
_runRepo = runRepo;
_listRepo = listRepo; _listRepo = listRepo;
_wtRepo = wtRepo;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_wtManager = wtManager; _wtManager = wtManager;
_argsBuilder = argsBuilder;
_cfg = cfg; _cfg = cfg;
_logger = logger; _logger = logger;
} }
public async Task RunAsync(Data.Models.TaskEntity task, string slot, CancellationToken ct) public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
{ {
try try
{ {
@@ -63,14 +73,19 @@ public sealed class TaskRunner
} }
else else
{ {
// Non-worktree sandbox path.
runDir = Path.Combine(_cfg.SandboxRoot, task.Id); runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
Directory.CreateDirectory(runDir); Directory.CreateDirectory(runDir);
} }
var logPath = Path.Combine(_cfg.LogRoot, $"{task.Id}.ndjson"); // Resolve config: task overrides > list config > null.
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
ResumeSessionId: null
);
await _taskRepo.SetLogPathAsync(task.Id, logPath, ct);
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(task.Id, now, ct); await _taskRepo.MarkRunningAsync(task.Id, now, ct);
await _broadcaster.TaskStarted(slot, task.Id, now); await _broadcaster.TaskStarted(slot, task.Id, now);
@@ -80,42 +95,38 @@ public sealed class TaskRunner
? task.Title ? task.Title
: $"{task.Title}\n\n{task.Description.Trim()}"; : $"{task.Title}\n\n{task.Description.Trim()}";
await using var logWriter = new LogWriter(logPath); // Run 1.
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
var result = await _claude.RunAsync(
prompt,
runDir,
logPath,
task.Id,
async line =>
{
await logWriter.WriteLineAsync(line, ct);
await _broadcaster.TaskMessage(task.Id, line);
},
ct);
var finishedAt = DateTime.UtcNow;
if (result.IsSuccess) if (result.IsSuccess)
{ {
// Auto-commit if worktree mode and run succeeded. await HandleSuccess(task, list, slot, wtCtx, result, ct);
if (wtCtx is not null)
{
var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed)
await _broadcaster.WorktreeUpdated(task.Id);
}
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
_logger.LogInformation("Task {TaskId} completed successfully", task.Id);
} }
else else
{ {
// Failed run: do NOT commit. Worktree row stays active for inspection. // Auto-retry: one attempt if we have a session ID.
await _taskRepo.MarkFailedAsync(task.Id, finishedAt, result.ErrorMarkdown, ct); if (result.SessionId is not null)
await _broadcaster.TaskFinished(slot, task.Id, "failed", finishedAt); {
_logger.LogWarning("Task {TaskId} failed: {Error}", task.Id, result.ErrorMarkdown); _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
await _broadcaster.RunCreated(task.Id, 2, true);
var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess)
{
await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
}
else
{
await HandleFailure(task.Id, slot, retryResult);
}
}
else
{
await HandleFailure(task.Id, slot, result);
}
} }
await _broadcaster.TaskUpdated(task.Id); await _broadcaster.TaskUpdated(task.Id);
@@ -132,6 +143,138 @@ public sealed class TaskRunner
} }
} }
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
{
var task = await _taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var list = await _listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
var resolvedConfig = new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model,
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
ResumeSessionId: lastRun.SessionId
);
// Determine run directory from existing worktree or sandbox.
string runDir;
WorktreeContext? wtCtx = null;
var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
if (worktree is not null)
{
runDir = worktree.Path;
wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
}
else
{
runDir = Path.Combine(_cfg.SandboxRoot, taskId);
}
var now = DateTime.UtcNow;
await _taskRepo.MarkRunningAsync(taskId, now, ct);
await _broadcaster.TaskStarted(slot, taskId, now);
var nextRunNumber = lastRun.RunNumber + 1;
await _broadcaster.RunCreated(taskId, nextRunNumber, false);
var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess)
{
await HandleSuccess(task, list, slot, wtCtx, result, ct);
}
else
{
await HandleFailure(taskId, slot, result);
}
await _broadcaster.TaskUpdated(taskId);
}
private async Task<RunResult> RunOnceAsync(
string taskId, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct)
{
var runId = Guid.NewGuid().ToString();
var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");
var run = new TaskRunEntity
{
Id = runId,
TaskId = taskId,
RunNumber = runNumber,
IsRetry = isRetry,
Prompt = prompt,
LogPath = logPath,
StartedAt = DateTime.UtcNow,
};
await _runRepo.AddAsync(run, ct);
var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath);
var result = await _claude.RunAsync(
arguments,
prompt,
runDir,
async line =>
{
await logWriter.WriteLineAsync(line, ct);
await _broadcaster.TaskMessage(taskId, line);
},
ct);
// Update the run record with results.
run.SessionId = result.SessionId;
run.ResultMarkdown = result.ResultMarkdown;
run.StructuredOutputJson = result.StructuredOutputJson;
run.ErrorMarkdown = result.ErrorMarkdown;
run.ExitCode = result.ExitCode;
run.TurnCount = result.TurnCount;
run.TokensIn = result.TokensIn;
run.TokensOut = result.TokensOut;
run.FinishedAt = DateTime.UtcNow;
await _runRepo.UpdateAsync(run, ct);
// Update denormalized fields on the task.
await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
return result;
}
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
{
if (wtCtx is not null)
{
var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed)
await _broadcaster.WorktreeUpdated(task.Id);
}
var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
}
private async Task HandleFailure(string taskId, string slot, RunResult result)
{
var finishedAt = DateTime.UtcNow;
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
}
private async Task MarkFailed(string taskId, string slot, string error) private async Task MarkFailed(string taskId, string slot, string error)
{ {
try try

View File

@@ -0,0 +1,76 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Services;
public sealed class AgentFileService
{
private readonly string _agentsDir;
public AgentFileService(string agentsDir)
{
_agentsDir = agentsDir;
}
public Task<List<AgentInfo>> ScanAsync(CancellationToken ct = default)
{
var agents = new List<AgentInfo>();
if (!Directory.Exists(_agentsDir))
return Task.FromResult(agents);
foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md"))
{
ct.ThrowIfCancellationRequested();
var (name, description) = ParseFrontmatter(file);
agents.Add(new AgentInfo(name, description, file));
}
agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(agents);
}
public async Task<string> ReadAsync(string path, CancellationToken ct = default)
{
return await File.ReadAllTextAsync(path, ct);
}
public async Task WriteAsync(string path, string content, CancellationToken ct = default)
{
var dir = Path.GetDirectoryName(path);
if (dir is not null) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(path, content, ct);
}
public Task DeleteAsync(string path, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (File.Exists(path)) File.Delete(path);
return Task.CompletedTask;
}
private static (string name, string description) ParseFrontmatter(string filePath)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
string name = fileName;
string description = "";
try
{
using var reader = new StreamReader(filePath);
var firstLine = reader.ReadLine();
if (firstLine?.Trim() != "---")
return (name, description);
while (reader.ReadLine() is { } line)
{
if (line.Trim() == "---") break;
if (line.StartsWith("name:"))
name = line["name:".Length..].Trim();
else if (line.StartsWith("description:"))
description = line["description:".Length..].Trim();
}
}
catch { /* Can't read file -- use filename fallback */ }
return (name, description);
}
}

View File

@@ -75,6 +75,31 @@ public sealed class QueueService : BackgroundService
} }
} }
public async Task<string> ContinueTask(string taskId, string followUpPrompt)
{
var task = await _taskRepo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
if (task.Status == Data.Models.TaskStatus.Running)
throw new InvalidOperationException("Task is currently running.");
lock (_lock)
{
if (_overrideSlot is not null)
throw new InvalidOperationException("override slot busy");
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
{
lock (_lock) { _overrideSlot = null; }
}, TaskScheduler.Default);
}
return taskId;
}
public bool CancelTask(string taskId) public bool CancelTask(string taskId)
{ {
lock (_lock) lock (_lock)
@@ -159,4 +184,17 @@ public sealed class QueueService : BackgroundService
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id); _logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
} }
} }
private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
{
try
{
_logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
}
}
} }

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,138 @@
using ClaudeDo.Ui.Helpers;
namespace ClaudeDo.Ui.Tests.Helpers;
public class StreamLineFormatterTests
{
private readonly StreamLineFormatter _formatter = new();
// --- Text deltas ---
[Fact]
public void FormatLine_TextDelta_ReturnsTextContent()
{
var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello world"}}}""";
Assert.Equal("Hello world", _formatter.FormatLine(line));
}
[Fact]
public void FormatLine_ConsecutiveTextDeltas_ReturnEachDelta()
{
var line1 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}}""";
var line2 = """{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}}""";
Assert.Equal("Hello ", _formatter.FormatLine(line1));
Assert.Equal("world", _formatter.FormatLine(line2));
}
[Fact]
public void FormatLine_ContentBlockStop_ReturnsNewline()
{
var line = """{"type":"stream_event","event":{"type":"content_block_stop","index":0}}""";
Assert.Equal("\n", _formatter.FormatLine(line));
}
// --- Tool use, result, system, fallback ---
[Fact]
public void FormatLine_ToolUseStart_ReturnsToolNameLine()
{
var line = """{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""";
Assert.Equal("\n[Tool: bash]\n", _formatter.FormatLine(line));
}
[Fact]
public void FormatLine_InputJsonDelta_ReturnsNull()
{
var line = """{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":"}}}""";
Assert.Null(_formatter.FormatLine(line));
}
[Fact]
public void FormatLine_Result_ReturnsFormattedResult()
{
var line = """{"type":"result","result":"Done."}""";
Assert.Equal("\n--- Result ---\nDone.\n", _formatter.FormatLine(line));
}
[Fact]
public void FormatLine_ApiRetry_ReturnsRetryNotice()
{
var line = """{"type":"system","subtype":"api_retry"}""";
Assert.Equal("\n[Retrying API call...]\n", _formatter.FormatLine(line));
}
[Fact]
public void FormatLine_SystemNonRetry_ReturnsNull()
{
var line = """{"type":"system","subtype":"init"}""";
Assert.Null(_formatter.FormatLine(line));
}
[Fact]
public void FormatLine_AssistantType_ReturnsNull()
{
var line = """{"type":"assistant","message":{}}""";
Assert.Null(_formatter.FormatLine(line));
}
[Fact]
public void FormatLine_MalformedJson_ReturnsRawLine()
{
var line = "not json at all";
Assert.Equal("not json at all", _formatter.FormatLine(line));
}
[Fact]
public void FormatLine_MessageStartAndDelta_ReturnsNull()
{
var start = """{"type":"stream_event","event":{"type":"message_start","message":{}}}""";
var delta = """{"type":"stream_event","event":{"type":"message_delta","delta":{}}}""";
Assert.Null(_formatter.FormatLine(start));
Assert.Null(_formatter.FormatLine(delta));
}
// --- FormatFile and Trim ---
[Fact]
public void FormatFile_ParsesAllLinesAndReturnsFormattedText()
{
var lines = new[]
{
"""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}""",
"""{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"x","name":"bash"}}}""",
"""{"type":"result","result":"Done."}""",
};
var file = Path.GetTempFileName();
try
{
File.WriteAllLines(file, lines);
var result = _formatter.FormatFile(file);
Assert.Contains("Hello", result);
Assert.Contains("[Tool: bash]", result);
Assert.Contains("Done.", result);
}
finally
{
File.Delete(file);
}
}
[Fact]
public void FormatFile_TrimsLargeContent()
{
var chunk = new string('x', 1000);
var line = "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"" + chunk + "\"}}}";
var lines = Enumerable.Repeat(line, 65).ToArray();
var file = Path.GetTempFileName();
try
{
File.WriteAllLines(file, lines);
var result = _formatter.FormatFile(file);
Assert.True(result.Length <= 50_200, $"Expected <= 50200 but got {result.Length}");
}
finally
{
File.Delete(file);
}
}
}

View File

@@ -0,0 +1,41 @@
# ClaudeDo.Worker.Tests
xUnit integration tests for the Worker and Data layers.
## Framework
- xUnit 2.5.3 with `xunit.runner.visualstudio`
- No mocking library — custom sealed fakes (FakeClaudeProcess, FakeHubContext, FakeHubClients, FakeClientProxy)
- Real SQLite databases per test via `DbFixture`
- Real git repos for worktree tests via `GitRepoFixture`
- coverlet for coverage collection
## Test Infrastructure
- **DbFixture** — creates unique temp SQLite DB, applies schema, cleans up DB + WAL/SHM files on dispose
- **GitRepoFixture** — creates temp git repo with initial commit, configures user/email, handles Windows read-only .git cleanup. Tests skip if git is unavailable.
## Test Areas
| Area | File | What it covers |
|------|------|----------------|
| Repositories | `ListRepositoryTests` | CRUD, tag junctions |
| | `TaskRepositoryTests` | CRUD, status transitions, agent tag filtering, effective tags, stale flip |
| Runner | `WorktreeManagerTests` | Worktree creation, commit detection, error on non-git dir |
| | `CommitMessageBuilderTests` | Slug generation, title/description truncation |
| | `MessageParserTests` | NDJSON parsing, malformed input |
| Services | `QueueServiceTests` | FIFO ordering, override slot contention, cancellation, active tracking |
| | `StaleTaskRecoveryTests` | Flips orphaned running tasks to failed |
## Conventions
- Test classes implement `IDisposable` and create fixtures in constructor
- Helper factory methods for entities: `MakeTask()`, `CreateListAsync()`, `SeedListWithAgentTag()`
- Concurrency tests use `TaskCompletionSource` as gates for deterministic ordering
- Git-dependent tests are conditionally skipped via `Skip = ...` when git is not available
## Running
```bash
dotnet test tests/ClaudeDo.Worker.Tests
```

View File

@@ -0,0 +1,61 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class ListRepositoryConfigTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ListRepository _repo;
private readonly string _listId;
public ListRepositoryConfigTests()
{
_repo = new ListRepository(_db.Factory);
_listId = Guid.NewGuid().ToString();
_repo.AddAsync(new ListEntity
{
Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow
}).GetAwaiter().GetResult();
}
[Fact]
public async Task GetConfig_Returns_Null_When_No_Config()
{
var config = await _repo.GetConfigAsync(_listId);
Assert.Null(config);
}
[Fact]
public async Task SetConfig_And_GetConfig_Roundtrips()
{
var config = new ListConfigEntity
{
ListId = _listId,
Model = "sonnet-4-6",
SystemPrompt = "You are helpful.",
AgentPath = "/home/user/.todo-app/agents/dev.md",
};
await _repo.SetConfigAsync(config);
var fetched = await _repo.GetConfigAsync(_listId);
Assert.NotNull(fetched);
Assert.Equal("sonnet-4-6", fetched.Model);
Assert.Equal("You are helpful.", fetched.SystemPrompt);
Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath);
}
[Fact]
public async Task SetConfig_Upserts_On_Duplicate()
{
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "opus-4-6" });
await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, Model = "haiku-4-5" });
var fetched = await _repo.GetConfigAsync(_listId);
Assert.NotNull(fetched);
Assert.Equal("haiku-4-5", fetched.Model);
}
public void Dispose() => _db.Dispose();
}

View File

@@ -0,0 +1,170 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRunRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TaskRunRepository _runs;
private readonly string _taskId;
public TaskRunRepositoryTests()
{
_runs = new TaskRunRepository(_db.Factory);
// Seed a list and task for all tests
var lists = new ListRepository(_db.Factory);
var tasks = new TaskRepository(_db.Factory);
var listId = Guid.NewGuid().ToString();
lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
}).GetAwaiter().GetResult();
_taskId = Guid.NewGuid().ToString();
tasks.AddAsync(new TaskEntity
{
Id = _taskId,
ListId = listId,
Title = "Test Task",
Status = Data.Models.TaskStatus.Queued,
CreatedAt = DateTime.UtcNow,
CommitType = "feat",
}).GetAwaiter().GetResult();
}
public void Dispose() => _db.Dispose();
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
RunNumber = runNumber,
IsRetry = isRetry,
Prompt = $"Do something (run {runNumber})",
StartedAt = DateTime.UtcNow,
};
[Fact]
public async Task Add_And_GetById_Roundtrips()
{
var entity = new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
RunNumber = 1,
SessionId = "sess-abc",
IsRetry = false,
Prompt = "Fix the bug",
ResultMarkdown = "All done",
StructuredOutputJson = """{"ok":true}""",
ErrorMarkdown = null,
ExitCode = 0,
TurnCount = 5,
TokensIn = 1000,
TokensOut = 2000,
LogPath = "/tmp/run1.ndjson",
StartedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
FinishedAt = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc),
};
await _runs.AddAsync(entity);
var loaded = await _runs.GetByIdAsync(entity.Id);
Assert.NotNull(loaded);
Assert.Equal(entity.Id, loaded.Id);
Assert.Equal(entity.TaskId, loaded.TaskId);
Assert.Equal(entity.RunNumber, loaded.RunNumber);
Assert.Equal(entity.SessionId, loaded.SessionId);
Assert.Equal(entity.IsRetry, loaded.IsRetry);
Assert.Equal(entity.Prompt, loaded.Prompt);
Assert.Equal(entity.ResultMarkdown, loaded.ResultMarkdown);
Assert.Equal(entity.StructuredOutputJson, loaded.StructuredOutputJson);
Assert.Null(loaded.ErrorMarkdown);
Assert.Equal(entity.ExitCode, loaded.ExitCode);
Assert.Equal(entity.TurnCount, loaded.TurnCount);
Assert.Equal(entity.TokensIn, loaded.TokensIn);
Assert.Equal(entity.TokensOut, loaded.TokensOut);
Assert.Equal(entity.LogPath, loaded.LogPath);
Assert.Equal(entity.StartedAt, loaded.StartedAt);
Assert.Equal(entity.FinishedAt, loaded.FinishedAt);
}
[Fact]
public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
{
var run3 = MakeRun(3);
var run1 = MakeRun(1);
var run2 = MakeRun(2);
await _runs.AddAsync(run3);
await _runs.AddAsync(run1);
await _runs.AddAsync(run2);
var runs = await _runs.GetByTaskIdAsync(_taskId);
Assert.Equal(3, runs.Count);
Assert.Equal(1, runs[0].RunNumber);
Assert.Equal(2, runs[1].RunNumber);
Assert.Equal(3, runs[2].RunNumber);
}
[Fact]
public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
{
var run1 = MakeRun(1);
var run2 = MakeRun(2);
await _runs.AddAsync(run1);
await _runs.AddAsync(run2);
var latest = await _runs.GetLatestByTaskIdAsync(_taskId);
Assert.NotNull(latest);
Assert.Equal(run2.Id, latest.Id);
Assert.Equal(2, latest.RunNumber);
}
[Fact]
public async Task Update_Persists_Completion_Fields()
{
var run = MakeRun(1);
await _runs.AddAsync(run);
run.SessionId = "sess-xyz";
run.ResultMarkdown = "Task completed";
run.StructuredOutputJson = """{"status":"done"}""";
run.ErrorMarkdown = null;
run.ExitCode = 0;
run.TurnCount = 12;
run.TokensIn = 5000;
run.TokensOut = 8000;
run.FinishedAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
await _runs.UpdateAsync(run);
var loaded = await _runs.GetByIdAsync(run.Id);
Assert.NotNull(loaded);
Assert.Equal("sess-xyz", loaded.SessionId);
Assert.Equal("Task completed", loaded.ResultMarkdown);
Assert.Equal("""{"status":"done"}""", loaded.StructuredOutputJson);
Assert.Null(loaded.ErrorMarkdown);
Assert.Equal(0, loaded.ExitCode);
Assert.Equal(12, loaded.TurnCount);
Assert.Equal(5000, loaded.TokensIn);
Assert.Equal(8000, loaded.TokensOut);
Assert.Equal(new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc), loaded.FinishedAt);
}
[Fact]
public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
{
var latest = await _runs.GetLatestByTaskIdAsync(Guid.NewGuid().ToString());
Assert.Null(latest);
}
}

View File

@@ -0,0 +1,71 @@
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class ClaudeArgsBuilderTests
{
private readonly ClaudeArgsBuilder _builder = new();
[Fact]
public void Default_Config_Produces_Base_Args()
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
Assert.Contains("-p", args);
Assert.Contains("--output-format stream-json", args);
Assert.Contains("--verbose", args);
Assert.Contains("--dangerously-skip-permissions", args);
Assert.Contains("--json-schema", args);
Assert.DoesNotContain("--model", args);
Assert.DoesNotContain("--append-system-prompt", args);
Assert.DoesNotContain("--agents", args);
Assert.DoesNotContain("--resume", args);
}
[Fact]
public void Model_Adds_Model_Flag()
{
var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
Assert.Contains("--model sonnet-4-6", args);
}
[Fact]
public void SystemPrompt_Adds_Append_System_Prompt_Flag()
{
var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null));
Assert.Contains("--append-system-prompt", args);
Assert.Contains("Be concise.", args);
}
[Fact]
public void AgentPath_Adds_Agents_Flag_As_Json()
{
var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
Assert.Contains("--agents", args);
Assert.Contains("/path/to/agent.md", args);
}
[Fact]
public void ResumeSessionId_Adds_Resume_Flag()
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
Assert.Contains("--resume sess-abc-123", args);
}
[Fact]
public void All_Options_Set_Includes_All_Flags()
{
var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
Assert.Contains("--model opus-4-6", args);
Assert.Contains("--append-system-prompt", args);
Assert.Contains("--agents", args);
Assert.Contains("--resume sess-xyz", args);
Assert.Contains("--json-schema", args);
}
[Fact]
public void SystemPrompt_With_Quotes_Is_Escaped()
{
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
Assert.Contains("--append-system-prompt", args);
}
}

View File

@@ -1,53 +0,0 @@
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class MessageParserTests
{
[Fact]
public void WellFormed_Result_Line_Extracts_Result()
{
var line = """{"type":"result","result":"Hello **world**"}""";
Assert.True(MessageParser.TryExtractResult(line, out var result));
Assert.Equal("Hello **world**", result);
}
[Fact]
public void Non_Result_Type_Returns_False()
{
var line = """{"type":"assistant","message":"hi"}""";
Assert.False(MessageParser.TryExtractResult(line, out var result));
Assert.Null(result);
}
[Fact]
public void Missing_Type_Property_Returns_False()
{
var line = """{"result":"data"}""";
Assert.False(MessageParser.TryExtractResult(line, out var result));
Assert.Null(result);
}
[Fact]
public void Malformed_Json_Returns_False_No_Throw()
{
var line = "this is not json {{{";
Assert.False(MessageParser.TryExtractResult(line, out var result));
Assert.Null(result);
}
[Fact]
public void Empty_Line_Returns_False()
{
Assert.False(MessageParser.TryExtractResult("", out _));
Assert.False(MessageParser.TryExtractResult(" ", out _));
}
[Fact]
public void Null_Result_Value_Returns_True_With_Null()
{
var line = """{"type":"result","result":null}""";
Assert.True(MessageParser.TryExtractResult(line, out var result));
Assert.Null(result);
}
}

View File

@@ -0,0 +1,82 @@
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class StreamAnalyzerTests
{
[Fact]
public void Extracts_Result_Markdown()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}""");
var result = analyzer.GetResult();
Assert.Equal("## Done", result.ResultMarkdown);
Assert.Equal("sess-1", result.SessionId);
}
[Fact]
public void Extracts_Structured_Output()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal("ok", result.ResultMarkdown);
Assert.Contains("all good", result.StructuredOutputJson);
}
[Fact]
public void Counts_Assistant_Turns()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal(2, result.TurnCount);
}
[Fact]
public void Accumulates_Token_Usage()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}""");
analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal(300, result.TokensIn);
Assert.Equal(130, result.TokensOut);
}
[Fact]
public void Counts_Api_Retry_Events()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}""");
analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal(2, result.ApiRetryCount);
}
[Fact]
public void Malformed_Json_Is_Ignored()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("not json {{{");
analyzer.ProcessLine("");
analyzer.ProcessLine(" ");
var result = analyzer.GetResult();
Assert.Null(result.ResultMarkdown);
Assert.Equal(0, result.TurnCount);
}
[Fact]
public void No_Result_Event_Returns_Null_Fields()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
var result = analyzer.GetResult();
Assert.Null(result.ResultMarkdown);
Assert.Null(result.SessionId);
}
}

View File

@@ -0,0 +1,84 @@
using ClaudeDo.Worker.Services;
namespace ClaudeDo.Worker.Tests.Services;
public sealed class AgentFileServiceTests : IDisposable
{
private readonly string _agentDir;
private readonly AgentFileService _service;
public AgentFileServiceTests()
{
_agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_agentDir);
_service = new AgentFileService(_agentDir);
}
[Fact]
public async Task Scan_Returns_Empty_For_Empty_Directory()
{
var agents = await _service.ScanAsync();
Assert.Empty(agents);
}
[Fact]
public async Task Scan_Parses_Frontmatter()
{
var content = "---\nname: Test Agent\ndescription: A test agent for unit tests\n---\n\nYou are a test agent.";
await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content);
var agents = await _service.ScanAsync();
Assert.Single(agents);
Assert.Equal("Test Agent", agents[0].Name);
Assert.Equal("A test agent for unit tests", agents[0].Description);
Assert.EndsWith("test.md", agents[0].Path);
}
[Fact]
public async Task Scan_Uses_Filename_When_No_Frontmatter()
{
await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions.");
var agents = await _service.ScanAsync();
Assert.Single(agents);
Assert.Equal("simple", agents[0].Name);
Assert.Equal("", agents[0].Description);
}
[Fact]
public async Task Write_And_Read_Roundtrips()
{
var path = Path.Combine(_agentDir, "new-agent.md");
var content = "---\nname: New\ndescription: Desc\n---\nBody";
await _service.WriteAsync(path, content);
var read = await _service.ReadAsync(path);
Assert.Equal(content, read);
}
[Fact]
public async Task Delete_Removes_File()
{
var path = Path.Combine(_agentDir, "to-delete.md");
await File.WriteAllTextAsync(path, "temp");
await _service.DeleteAsync(path);
Assert.False(File.Exists(path));
}
[Fact]
public async Task Scan_Ignores_Non_Md_Files()
{
await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent");
await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody");
var agents = await _service.ScanAsync();
Assert.Single(agents);
Assert.Equal("Real", agents[0].Name);
}
public void Dispose()
{
try { Directory.Delete(_agentDir, true); } catch { }
}
}

View File

@@ -43,13 +43,15 @@ public sealed class QueueServiceTests : IDisposable
} }
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService( private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
Func<string, string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null) Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
{ {
var fake = new FakeClaudeProcess(handler); var fake = new FakeClaudeProcess(handler);
var broadcaster = new HubBroadcaster(new FakeHubContext()); var broadcaster = new HubBroadcaster(new FakeHubContext());
var wtRepo = new WorktreeRepository(_db.Factory); var wtRepo = new WorktreeRepository(_db.Factory);
var runRepo = new TaskRunRepository(_db.Factory);
var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance); var wtManager = new WorktreeManager(new GitService(), wtRepo, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, _taskRepo, _listRepo, broadcaster, wtManager, _cfg, var argsBuilder = new ClaudeArgsBuilder();
var runner = new TaskRunner(fake, _taskRepo, runRepo, _listRepo, wtRepo, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance); NullLogger<TaskRunner>.Instance);
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance); var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
return (service, fake); return (service, fake);
@@ -88,7 +90,7 @@ public sealed class QueueServiceTests : IDisposable
var (listId, _) = await SeedListWithAgentTag(); var (listId, _) = await SeedListWithAgentTag();
var tcs = new TaskCompletionSource<RunResult>(); var tcs = new TaskCompletionSource<RunResult>();
var (service, _) = CreateService((_, _, _, _, _, ct) => tcs.Task); var (service, _) = CreateService((_, _, _, _, ct) => tcs.Task);
var task1 = await SeedQueuedTask(listId); var task1 = await SeedQueuedTask(listId);
var task2 = await SeedQueuedTask(listId); var task2 = await SeedQueuedTask(listId);
@@ -114,7 +116,7 @@ public sealed class QueueServiceTests : IDisposable
var (listId, _) = await SeedListWithAgentTag(); var (listId, _) = await SeedListWithAgentTag();
await SeedQueuedTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1)); await SeedQueuedTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1));
var (service, fake) = CreateService((_, _, _, _, _, _) => var (service, fake) = CreateService((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
@@ -139,17 +141,17 @@ public sealed class QueueServiceTests : IDisposable
var gate2 = new TaskCompletionSource(); var gate2 = new TaskCompletionSource();
var callCount = 0; var callCount = 0;
var (service, _) = CreateService(async (prompt, _, _, taskId, _, ct) => var (service, _) = CreateService(async (_, _, _, _, ct) =>
{ {
var n = Interlocked.Increment(ref callCount); var n = Interlocked.Increment(ref callCount);
lock (order) { order.Add(taskId); } lock (order) { order.Add(n.ToString()); }
if (n == 1) await gate1.Task; if (n == 1) await gate1.Task;
if (n == 2) gate2.SetResult(); if (n == 2) gate2.SetResult();
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" }; return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
}); });
var task1 = await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-2)); await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-2));
var task2 = await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-1)); await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-1));
using var cts = new CancellationTokenSource(); using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token); await service.StartAsync(cts.Token);
@@ -162,7 +164,7 @@ public sealed class QueueServiceTests : IDisposable
// Only task1 should be running (task2 waiting on the queue slot). // Only task1 should be running (task2 waiting on the queue slot).
Assert.Single(order); Assert.Single(order);
Assert.Equal(task1.Id, order[0]); Assert.Equal("1", order[0]);
// Release first task. // Release first task.
gate1.SetResult(); gate1.SetResult();
@@ -171,7 +173,7 @@ public sealed class QueueServiceTests : IDisposable
await gate2.Task.WaitAsync(TimeSpan.FromSeconds(5)); await gate2.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, order.Count); Assert.Equal(2, order.Count);
Assert.Equal(task2.Id, order[1]); Assert.Equal("2", order[1]);
cts.Cancel(); cts.Cancel();
} }
@@ -184,7 +186,7 @@ public sealed class QueueServiceTests : IDisposable
var running = new TaskCompletionSource(); var running = new TaskCompletionSource();
var cancelled = false; var cancelled = false;
var (service, _) = CreateService(async (_, _, _, _, _, ct) => var (service, _) = CreateService(async (_, _, _, _, ct) =>
{ {
running.SetResult(); running.SetResult();
try try
@@ -211,13 +213,55 @@ public sealed class QueueServiceTests : IDisposable
Assert.True(cancelled); Assert.True(cancelled);
} }
[Fact]
public async Task RunNow_AutoRetries_On_Failure_With_SessionId()
{
var (listId, agentTagId) = await SeedListWithAgentTag();
var task = await SeedQueuedTask(listId);
var callCount = 0;
var (service, fake) = CreateService((prompt, dir, args, onLine, ct) =>
{
callCount++;
if (callCount == 1)
{
return Task.FromResult(new RunResult
{
ExitCode = 1,
ErrorMarkdown = "something broke",
SessionId = "sess-retry-test",
});
}
return Task.FromResult(new RunResult
{
ExitCode = 0,
ResultMarkdown = "fixed it",
SessionId = "sess-retry-test",
});
});
await service.StartAsync(CancellationToken.None);
await service.RunNow(task.Id);
// Wait for both runs to complete.
await Task.Delay(2000);
await service.StopAsync(CancellationToken.None);
Assert.Equal(2, callCount);
var finalTask = await _taskRepo.GetByIdAsync(task.Id);
Assert.NotNull(finalTask);
Assert.Equal(TaskStatus.Done, finalTask.Status);
}
[Fact] [Fact]
public async Task GetActive_Returns_Running_Slots() public async Task GetActive_Returns_Running_Slots()
{ {
var (listId, _) = await SeedListWithAgentTag(); var (listId, _) = await SeedListWithAgentTag();
var tcs = new TaskCompletionSource<RunResult>(); var tcs = new TaskCompletionSource<RunResult>();
var (service, _) = CreateService((_, _, _, _, _, _) => tcs.Task); var (service, _) = CreateService((_, _, _, _, _) => tcs.Task);
var task = await SeedQueuedTask(listId); var task = await SeedQueuedTask(listId);
await service.RunNow(task.Id); await service.RunNow(task.Id);
@@ -235,23 +279,23 @@ public sealed class QueueServiceTests : IDisposable
internal sealed class FakeClaudeProcess : IClaudeProcess internal sealed class FakeClaudeProcess : IClaudeProcess
{ {
private readonly Func<string, string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>> _handler; private readonly Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>> _handler;
private int _callCount; private int _callCount;
public int CallCount => _callCount; public int CallCount => _callCount;
public FakeClaudeProcess( public FakeClaudeProcess(
Func<string, string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null) Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
{ {
_handler = handler ?? ((_, _, _, _, _, _) => _handler = handler ?? ((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
} }
public async Task<RunResult> RunAsync(string prompt, string workingDirectory, string logPath, string taskId, public async Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
Func<string, Task> onStdoutLine, CancellationToken ct) Func<string, Task> onStdoutLine, CancellationToken ct)
{ {
Interlocked.Increment(ref _callCount); Interlocked.Increment(ref _callCount);
return await _handler(prompt, workingDirectory, logPath, taskId, onStdoutLine, ct); return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct);
} }
} }