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:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
107
.superpowers/brainstorm/1955-1776152447/content/color-theme.html
Normal file
107
.superpowers/brainstorm/1955-1776152447/content/color-theme.html
Normal 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>
|
||||||
@@ -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 · 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 · 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1776154800894}
|
||||||
1
.superpowers/brainstorm/1955-1776152447/state/server.pid
Normal file
1
.superpowers/brainstorm/1955-1776152447/state/server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1955
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1776158304159}
|
||||||
1
.superpowers/brainstorm/3761-1776156321/state/server.pid
Normal file
1
.superpowers/brainstorm/3761-1776156321/state/server.pid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3761
|
||||||
54
CLAUDE.md
Normal file
54
CLAUDE.md
Normal 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
|
||||||
@@ -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
107
README.md
Normal 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
92
docs/improvement-plan.md
Normal 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 (~10–15 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:** klein–mittel — 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:** klein–mittel
|
||||||
|
**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
193
docs/open.md
Normal 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 1–3 sind ein realistischer Block für eine Session.
|
||||||
1550
docs/superpowers/plans/2026-04-14-ui-fixes.md
Normal file
1550
docs/superpowers/plans/2026-04-14-ui-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
2311
docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
Normal file
2311
docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
Normal file
File diff suppressed because it is too large
Load Diff
216
docs/superpowers/specs/2026-04-14-ui-fixes-design.md
Normal file
216
docs/superpowers/specs/2026-04-14-ui-fixes-design.md
Normal 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`
|
||||||
@@ -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`.
|
||||||
@@ -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');
|
||||||
|
|||||||
29
src/ClaudeDo.App/CLAUDE.md
Normal file
29
src/ClaudeDo.App/CLAUDE.md
Normal 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)
|
||||||
41
src/ClaudeDo.Data/CLAUDE.md
Normal file
41
src/ClaudeDo.Data/CLAUDE.md
Normal 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
|
||||||
3
src/ClaudeDo.Data/Models/AgentInfo.cs
Normal file
3
src/ClaudeDo.Data/Models/AgentInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public sealed record AgentInfo(string Name, string Description, string Path);
|
||||||
9
src/ClaudeDo.Data/Models/ListConfigEntity.cs
Normal file
9
src/ClaudeDo.Data/Models/ListConfigEntity.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/ClaudeDo.Data/Models/TaskRunEntity.cs
Normal file
21
src/ClaudeDo.Data/Models/TaskRunEntity.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
139
src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
Normal file
139
src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
Normal 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
|
||||||
|
}
|
||||||
@@ -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
49
src/ClaudeDo.Ui/CLAUDE.md
Normal 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
|
||||||
115
src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
Normal file
115
src/ClaudeDo.Ui/Helpers/StreamLineFormatter.cs
Normal 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..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; } = "";
|
||||||
|
|||||||
@@ -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?>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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?>();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
67
src/ClaudeDo.Worker/CLAUDE.md
Normal file
67
src/ClaudeDo.Worker/CLAUDE.md
Normal 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
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
|||||||
65
src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs
Normal file
65
src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs
Normal file
79
src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/ClaudeDo.Worker/Runner/StreamResult.cs
Normal file
12
src/ClaudeDo.Worker/Runner/StreamResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
76
src/ClaudeDo.Worker/Services/AgentFileService.cs
Normal file
76
src/ClaudeDo.Worker/Services/AgentFileService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
Normal file
20
tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
Normal 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>
|
||||||
138
tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
Normal file
138
tests/ClaudeDo.Ui.Tests/Helpers/StreamLineFormatterTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/ClaudeDo.Worker.Tests/CLAUDE.md
Normal file
41
tests/ClaudeDo.Worker.Tests/CLAUDE.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
Normal file
71
tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
82
tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
Normal file
82
tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user