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" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
|
||||
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
|
||||
</Folder>
|
||||
</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)
|
||||
);
|
||||
|
||||
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 (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
@@ -57,6 +64,27 @@ CREATE TABLE IF NOT EXISTS worktrees (
|
||||
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)
|
||||
INSERT OR IGNORE INTO tags (name) VALUES ('agent');
|
||||
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? FinishedAt { get; set; }
|
||||
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);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
|
||||
@@ -42,9 +42,11 @@ public sealed class TaskRepository
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
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,
|
||||
@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);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
@@ -58,7 +60,8 @@ public sealed class TaskRepository
|
||||
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
|
||||
status = @status, scheduled_for = @scheduled_for, result = @result,
|
||||
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
|
||||
""";
|
||||
BindTask(cmd, entity);
|
||||
@@ -78,7 +81,7 @@ public sealed class TaskRepository
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
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);
|
||||
|
||||
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 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);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
@@ -175,7 +178,8 @@ public sealed class TaskRepository
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
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
|
||||
WHERE t.status = 'queued'
|
||||
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("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
|
||||
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()
|
||||
@@ -293,6 +300,9 @@ public sealed class TaskRepository
|
||||
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
|
||||
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
|
||||
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
|
||||
|
||||
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.ExecuteNonQuery();
|
||||
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()
|
||||
|
||||
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 Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
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>? TaskUpdatedEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? RunNowRequestedEvent;
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
{
|
||||
@@ -162,6 +164,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
|
||||
public async Task RunNowAsync(string taskId)
|
||||
{
|
||||
RunNowRequestedEvent?.Invoke(taskId);
|
||||
await _hub.InvokeAsync("RunNow", taskId);
|
||||
}
|
||||
|
||||
@@ -175,6 +178,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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()
|
||||
{
|
||||
try
|
||||
@@ -200,7 +221,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
await _hub.DisposeAsync();
|
||||
}
|
||||
|
||||
// DTO for deserializing the GetActive response
|
||||
// DTOs for deserializing hub responses
|
||||
private sealed class ActiveTaskDto
|
||||
{
|
||||
public string Slot { get; set; } = "";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using AgentInfo = ClaudeDo.Data.Models.AgentInfo;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
@@ -11,6 +13,11 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _defaultCommitType = "chore";
|
||||
[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 DateTime _createdAt;
|
||||
private TaskCompletionSource<ListEntity?> _tcs = new();
|
||||
@@ -20,6 +27,31 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
public static string[] CommitTypes { get; } =
|
||||
["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()
|
||||
{
|
||||
_editId = null;
|
||||
@@ -27,7 +59,7 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
WindowTitle = "New List";
|
||||
}
|
||||
|
||||
public void InitForEdit(ListEntity entity)
|
||||
public void InitForEdit(ListEntity entity, ListConfigEntity? config)
|
||||
{
|
||||
_editId = entity.Id;
|
||||
_createdAt = entity.CreatedAt;
|
||||
@@ -35,6 +67,28 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
WorkingDir = entity.WorkingDir;
|
||||
DefaultCommitType = entity.DefaultCommitType;
|
||||
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]
|
||||
@@ -60,10 +114,11 @@ public partial class ListEditorViewModel : ViewModelBase
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the view to await the editor result.
|
||||
/// Returns the entity to persist or null if cancelled.
|
||||
/// </summary>
|
||||
public void OnWindowClosed()
|
||||
{
|
||||
_tcs.TrySetResult(null);
|
||||
}
|
||||
|
||||
public Task<ListEntity?> ShowAndWaitAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<ListEntity?>();
|
||||
|
||||
@@ -78,10 +78,12 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
private async Task AddList()
|
||||
{
|
||||
var editor = _listEditorFactory();
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForCreate();
|
||||
|
||||
var window = new ListEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var entity = await editor.ShowAndWaitAsync();
|
||||
@@ -90,6 +92,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
await _listRepo.AddAsync(entity);
|
||||
var configEntity = editor.BuildConfig(entity.Id);
|
||||
if (configEntity is not null)
|
||||
await _listRepo.SetConfigAsync(configEntity);
|
||||
Lists.Add(new ListItemViewModel(entity));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -105,11 +110,14 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
|
||||
if (existing is null) return;
|
||||
|
||||
var config = await _listRepo.GetConfigAsync(existing.Id);
|
||||
var editor = _listEditorFactory();
|
||||
editor.InitForEdit(existing);
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForEdit(existing, config);
|
||||
|
||||
var window = new ListEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var entity = await editor.ShowAndWaitAsync();
|
||||
@@ -118,6 +126,9 @@ public partial class MainWindowViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
await _listRepo.UpdateAsync(entity);
|
||||
var configEntity = editor.BuildConfig(entity.Id);
|
||||
if (configEntity is not null)
|
||||
await _listRepo.SetConfigAsync(configEntity);
|
||||
SelectedList.Name = entity.Name;
|
||||
SelectedList.WorkingDir = entity.WorkingDir;
|
||||
SelectedList.DefaultCommitType = entity.DefaultCommitType;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -37,14 +40,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _worktreeState = "";
|
||||
|
||||
// Live stream
|
||||
public ObservableCollection<string> LiveLines { get; } = new();
|
||||
[ObservableProperty] private string _liveText = "";
|
||||
private StreamLineFormatter _formatter = new();
|
||||
public ObservableCollection<TagEntity> Tags { get; } = new();
|
||||
[ObservableProperty] private string _newTagInput = "";
|
||||
|
||||
private string? _taskId;
|
||||
private string? _listId;
|
||||
private bool _isLoading;
|
||||
private const int MaxLiveLines = 500;
|
||||
|
||||
public event Action<string>? TaskChanged;
|
||||
|
||||
@@ -61,12 +64,15 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
worker.TaskMessageEvent += OnTaskMessage;
|
||||
worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
|
||||
worker.TaskUpdatedEvent += OnTaskUpdated;
|
||||
worker.RunNowRequestedEvent += OnRunNowRequested;
|
||||
worker.TaskStartedEvent += OnTaskStarted;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(string taskId)
|
||||
{
|
||||
_taskId = taskId;
|
||||
LiveLines.Clear();
|
||||
LiveText = "";
|
||||
_formatter = new StreamLineFormatter();
|
||||
|
||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null) return;
|
||||
@@ -79,6 +85,13 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
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();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
@@ -152,7 +165,8 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
LogPath = null;
|
||||
StatusText = "";
|
||||
HasWorktree = false;
|
||||
LiveLines.Clear();
|
||||
LiveText = "";
|
||||
_formatter = new StreamLineFormatter();
|
||||
Tags.Clear();
|
||||
NewTagInput = "";
|
||||
StatusChoice = "Manual";
|
||||
@@ -259,9 +273,27 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private void OnTaskMessage(string taskId, string line)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
if (LiveLines.Count >= MaxLiveLines)
|
||||
LiveLines.RemoveAt(0);
|
||||
LiveLines.Add(line);
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is not null)
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -13,6 +14,10 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _statusChoice = "manual";
|
||||
[ObservableProperty] private string _tagsInput = "";
|
||||
[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 _listId = "";
|
||||
@@ -21,12 +26,19 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
|
||||
public event Action? RequestClose;
|
||||
|
||||
public static string[] ModelChoices { get; } = ["(list default)", "Sonnet", "Opus", "Haiku"];
|
||||
|
||||
public static string[] CommitTypes { get; } =
|
||||
["chore", "feat", "fix", "refactor", "docs", "test", "perf", "style", "build", "ci"];
|
||||
|
||||
public static string[] StatusChoices { get; } =
|
||||
["manual", "queued"];
|
||||
|
||||
public async Task LoadAgentsAsync(WorkerClient worker)
|
||||
{
|
||||
AvailableAgents = await worker.GetAgentsAsync();
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> SelectedTagNames =>
|
||||
TagsInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct()
|
||||
@@ -56,6 +68,13 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
_ => entity.Status.ToString().ToLowerInvariant(),
|
||||
};
|
||||
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}";
|
||||
}
|
||||
|
||||
@@ -78,6 +97,11 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
CommitType = CommitType,
|
||||
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);
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
@@ -89,6 +113,11 @@ public partial class TaskEditorViewModel : ViewModelBase
|
||||
RequestClose?.Invoke();
|
||||
}
|
||||
|
||||
public void OnWindowClosed()
|
||||
{
|
||||
_tcs.TrySetResult(null);
|
||||
}
|
||||
|
||||
public Task<TaskEntity?> ShowAndWaitAsync()
|
||||
{
|
||||
_tcs = new TaskCompletionSource<TaskEntity?>();
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class TaskItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _commitType;
|
||||
[ObservableProperty] private string? _description;
|
||||
[ObservableProperty] private TaskStatus _status;
|
||||
[ObservableProperty] private bool _isStarting;
|
||||
|
||||
public string Id { get; }
|
||||
public string ListId { get; }
|
||||
@@ -66,6 +67,7 @@ public partial class TaskItemViewModel : ViewModelBase
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
IsStarting = false;
|
||||
OnPropertyChanged(nameof(CanToggleDone));
|
||||
OnPropertyChanged(nameof(TitleDecorations));
|
||||
OnPropertyChanged(nameof(TitleForeground));
|
||||
@@ -73,6 +75,19 @@ public partial class TaskItemViewModel : ViewModelBase
|
||||
ToggleDoneCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
public void SetStarting()
|
||||
{
|
||||
IsStarting = true;
|
||||
StatusText = "starting...";
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
public void ClearStarting()
|
||||
{
|
||||
IsStarting = false;
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunNow))]
|
||||
private async Task RunNowAsync()
|
||||
{
|
||||
@@ -81,7 +96,7 @@ public partial class TaskItemViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private bool CanRunNow() =>
|
||||
_canRunNow() && Status == TaskStatus.Queued;
|
||||
_canRunNow() && Status != TaskStatus.Running && !IsStarting;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanToggleDone))]
|
||||
private async Task ToggleDone()
|
||||
|
||||
@@ -55,6 +55,18 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
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)
|
||||
@@ -134,10 +146,12 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
var defaultCommitType = list?.DefaultCommitType ?? "chore";
|
||||
|
||||
var editor = _editorFactory();
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForCreate(CurrentListId, defaultCommitType);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var saved = await editor.ShowAndWaitAsync();
|
||||
@@ -179,10 +193,12 @@ public partial class TaskListViewModel : ViewModelBase
|
||||
|
||||
var taskTags = await _taskRepo.GetTagsAsync(entity.Id);
|
||||
var editor = _editorFactory();
|
||||
await editor.LoadAgentsAsync(_worker);
|
||||
editor.InitForEdit(entity, taskTags);
|
||||
|
||||
var window = new TaskEditorView { DataContext = editor };
|
||||
editor.RequestClose += () => window.Close();
|
||||
window.Closed += (_, _) => editor.OnWindowClosed();
|
||||
_ = ShowDialogAsync(window);
|
||||
|
||||
var saved = await editor.ShowAndWaitAsync();
|
||||
|
||||
@@ -1,27 +1,61 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:svc="using:ClaudeDo.Data.Models"
|
||||
x:Class="ClaudeDo.Ui.Views.ListEditorView"
|
||||
x:DataType="vm:ListEditorViewModel"
|
||||
Title="{Binding WindowTitle}"
|
||||
Width="450" Height="280"
|
||||
Width="450" Height="480"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False">
|
||||
CanResize="False"
|
||||
Background="{StaticResource WindowBgBrush}">
|
||||
<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..."/>
|
||||
|
||||
<TextBlock Text="Working Directory" FontWeight="SemiBold"/>
|
||||
<TextBox Text="{Binding WorkingDir}" PlaceholderText="(optional) Absolute path to git repo"/>
|
||||
<!-- TODO: folder picker button using IStorageProvider -->
|
||||
<TextBlock Text="Working Directory" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<DockPanel>
|
||||
<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}"
|
||||
SelectedItem="{Binding DefaultCommitType}"
|
||||
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">
|
||||
<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"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
@@ -8,4 +13,28 @@ public partial class ListEditorView : Window
|
||||
{
|
||||
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"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,8,0,2"/>
|
||||
<Border BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="6" MaxHeight="200">
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding LiveLines}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding}" FontFamily="Consolas,Courier New,monospace"
|
||||
FontSize="11" TextWrapping="NoWrap"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<TextBox x:Name="LiveOutputBox"
|
||||
Text="{Binding LiveText, Mode=OneWay}"
|
||||
IsReadOnly="True"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="NoWrap"
|
||||
FontFamily="Consolas,Courier New,monospace"
|
||||
FontSize="11"
|
||||
MaxHeight="300"
|
||||
Foreground="{StaticResource TextPrimaryBrush}"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="6"/>
|
||||
|
||||
<Border IsVisible="{Binding HasWorktree}" BorderBrush="{StaticResource AccentBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Margin="0,8,0,0">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
@@ -31,4 +32,28 @@ public partial class TaskDetailView : UserControl
|
||||
{
|
||||
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"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:models="using:ClaudeDo.Data.Models"
|
||||
x:Class="ClaudeDo.Ui.Views.TaskEditorView"
|
||||
x:DataType="vm:TaskEditorViewModel"
|
||||
Title="{Binding WindowTitle}"
|
||||
Width="500" Height="420"
|
||||
Width="500" Height="600"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False">
|
||||
CanResize="False"
|
||||
Background="{StaticResource WindowBgBrush}">
|
||||
<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..."/>
|
||||
|
||||
<TextBlock Text="Description" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Description" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<TextBox Text="{Binding Description}" PlaceholderText="(optional)" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" MinHeight="80"/>
|
||||
|
||||
<Grid ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Status" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="Status" FontWeight="SemiBold" Foreground="{StaticResource TextSecondaryBrush}"/>
|
||||
<ComboBox ItemsSource="{Binding StatusChoices}"
|
||||
SelectedItem="{Binding StatusChoice}"
|
||||
MinWidth="120"/>
|
||||
</StackPanel>
|
||||
<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}"
|
||||
SelectedItem="{Binding CommitType}"
|
||||
MinWidth="120"/>
|
||||
</StackPanel>
|
||||
</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, ..."/>
|
||||
|
||||
<!-- 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">
|
||||
<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"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -70,6 +70,10 @@
|
||||
Fill="{StaticResource StatusOrangeBrush}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Starting dot -->
|
||||
<Ellipse Width="8" Height="8" Fill="#FFD700"
|
||||
IsVisible="{Binding IsStarting}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Panel>
|
||||
</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) =>
|
||||
_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 ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Services;
|
||||
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";
|
||||
|
||||
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}";
|
||||
|
||||
@@ -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 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<TaskRepository>();
|
||||
builder.Services.AddSingleton<WorktreeRepository>();
|
||||
builder.Services.AddSingleton<TaskRunRepository>();
|
||||
builder.Services.AddHostedService<StaleTaskRecovery>();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
@@ -28,8 +29,14 @@ builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
||||
builder.Services.AddSingleton<HubBroadcaster>();
|
||||
builder.Services.AddSingleton<GitService>();
|
||||
builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
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).
|
||||
builder.Services.AddSingleton<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(
|
||||
string arguments,
|
||||
string prompt,
|
||||
string workingDirectory,
|
||||
string logPath,
|
||||
string taskId,
|
||||
Func<string, Task> onStdoutLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _cfg.ClaudeBin,
|
||||
Arguments = "-p --output-format stream-json --verbose --dangerously-skip-permissions",
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
@@ -40,30 +39,25 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
// Write prompt to stdin, then close.
|
||||
await process.StandardInput.WriteAsync(prompt);
|
||||
process.StandardInput.Close();
|
||||
|
||||
string? resultMarkdown = null;
|
||||
var analyzer = new StreamAnalyzer();
|
||||
var lastStderr = new StringBuilder();
|
||||
|
||||
// Register cancellation to kill the process tree.
|
||||
await using var ctr = ct.Register(() =>
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); }
|
||||
catch { /* already exited */ }
|
||||
});
|
||||
|
||||
// Read stdout and stderr concurrently.
|
||||
var stdoutTask = Task.Run(async () =>
|
||||
{
|
||||
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
await onStdoutLine(line);
|
||||
|
||||
if (MessageParser.TryExtractResult(line, out var res))
|
||||
resultMarkdown = res;
|
||||
analyzer.ProcessLine(line);
|
||||
}
|
||||
}, ct);
|
||||
|
||||
@@ -81,16 +75,34 @@ public sealed class ClaudeProcess : IClaudeProcess
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
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
|
||||
? lastStderr.ToString().Trim()
|
||||
: $"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
|
||||
{
|
||||
Task<RunResult> RunAsync(
|
||||
string arguments,
|
||||
string prompt,
|
||||
string workingDirectory,
|
||||
string logPath,
|
||||
string taskId,
|
||||
Func<string, Task> onStdoutLine,
|
||||
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 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;
|
||||
}
|
||||
|
||||
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.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
@@ -8,31 +9,40 @@ public sealed class TaskRunner
|
||||
{
|
||||
private readonly IClaudeProcess _claude;
|
||||
private readonly TaskRepository _taskRepo;
|
||||
private readonly TaskRunRepository _runRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly WorktreeRepository _wtRepo;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly ILogger<TaskRunner> _logger;
|
||||
|
||||
public TaskRunner(
|
||||
IClaudeProcess claude,
|
||||
TaskRepository taskRepo,
|
||||
TaskRunRepository runRepo,
|
||||
ListRepository listRepo,
|
||||
WorktreeRepository wtRepo,
|
||||
HubBroadcaster broadcaster,
|
||||
WorktreeManager wtManager,
|
||||
ClaudeArgsBuilder argsBuilder,
|
||||
WorkerConfig cfg,
|
||||
ILogger<TaskRunner> logger)
|
||||
{
|
||||
_claude = claude;
|
||||
_taskRepo = taskRepo;
|
||||
_runRepo = runRepo;
|
||||
_listRepo = listRepo;
|
||||
_wtRepo = wtRepo;
|
||||
_broadcaster = broadcaster;
|
||||
_wtManager = wtManager;
|
||||
_argsBuilder = argsBuilder;
|
||||
_cfg = cfg;
|
||||
_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
|
||||
{
|
||||
@@ -63,14 +73,19 @@ public sealed class TaskRunner
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-worktree sandbox path.
|
||||
runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
|
||||
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;
|
||||
await _taskRepo.MarkRunningAsync(task.Id, now, ct);
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
@@ -80,42 +95,38 @@ public sealed class TaskRunner
|
||||
? task.Title
|
||||
: $"{task.Title}\n\n{task.Description.Trim()}";
|
||||
|
||||
await using var logWriter = new LogWriter(logPath);
|
||||
|
||||
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;
|
||||
// Run 1.
|
||||
var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
// Auto-commit if worktree mode and run succeeded.
|
||||
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);
|
||||
await HandleSuccess(task, list, slot, wtCtx, result, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Failed run: do NOT commit. Worktree row stays active for inspection.
|
||||
await _taskRepo.MarkFailedAsync(task.Id, finishedAt, result.ErrorMarkdown, ct);
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "failed", finishedAt);
|
||||
_logger.LogWarning("Task {TaskId} failed: {Error}", task.Id, result.ErrorMarkdown);
|
||||
// Auto-retry: one attempt if we have a session ID.
|
||||
if (result.SessionId is not null)
|
||||
{
|
||||
_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);
|
||||
@@ -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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -159,4 +184,17 @@ public sealed class QueueService : BackgroundService
|
||||
_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(
|
||||
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 broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||
var wtRepo = new WorktreeRepository(_db.Factory);
|
||||
var runRepo = new TaskRunRepository(_db.Factory);
|
||||
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);
|
||||
var service = new QueueService(_taskRepo, runner, _cfg, NullLogger<QueueService>.Instance);
|
||||
return (service, fake);
|
||||
@@ -88,7 +90,7 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var (listId, _) = await SeedListWithAgentTag();
|
||||
var tcs = new TaskCompletionSource<RunResult>();
|
||||
|
||||
var (service, _) = CreateService((_, _, _, _, _, ct) => tcs.Task);
|
||||
var (service, _) = CreateService((_, _, _, _, ct) => tcs.Task);
|
||||
|
||||
var task1 = await SeedQueuedTask(listId);
|
||||
var task2 = await SeedQueuedTask(listId);
|
||||
@@ -114,7 +116,7 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var (listId, _) = await SeedListWithAgentTag();
|
||||
await SeedQueuedTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1));
|
||||
|
||||
var (service, fake) = CreateService((_, _, _, _, _, _) =>
|
||||
var (service, fake) = CreateService((_, _, _, _, _) =>
|
||||
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
@@ -139,17 +141,17 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var gate2 = new TaskCompletionSource();
|
||||
var callCount = 0;
|
||||
|
||||
var (service, _) = CreateService(async (prompt, _, _, taskId, _, ct) =>
|
||||
var (service, _) = CreateService(async (_, _, _, _, ct) =>
|
||||
{
|
||||
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 == 2) gate2.SetResult();
|
||||
return new RunResult { ExitCode = 0, ResultMarkdown = "ok" };
|
||||
});
|
||||
|
||||
var task1 = 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(-2));
|
||||
await SeedQueuedTask(listId, createdAt: DateTime.UtcNow.AddSeconds(-1));
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
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).
|
||||
Assert.Single(order);
|
||||
Assert.Equal(task1.Id, order[0]);
|
||||
Assert.Equal("1", order[0]);
|
||||
|
||||
// Release first task.
|
||||
gate1.SetResult();
|
||||
@@ -171,7 +173,7 @@ public sealed class QueueServiceTests : IDisposable
|
||||
await gate2.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(2, order.Count);
|
||||
Assert.Equal(task2.Id, order[1]);
|
||||
Assert.Equal("2", order[1]);
|
||||
|
||||
cts.Cancel();
|
||||
}
|
||||
@@ -184,7 +186,7 @@ public sealed class QueueServiceTests : IDisposable
|
||||
var running = new TaskCompletionSource();
|
||||
var cancelled = false;
|
||||
|
||||
var (service, _) = CreateService(async (_, _, _, _, _, ct) =>
|
||||
var (service, _) = CreateService(async (_, _, _, _, ct) =>
|
||||
{
|
||||
running.SetResult();
|
||||
try
|
||||
@@ -211,13 +213,55 @@ public sealed class QueueServiceTests : IDisposable
|
||||
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]
|
||||
public async Task GetActive_Returns_Running_Slots()
|
||||
{
|
||||
var (listId, _) = await SeedListWithAgentTag();
|
||||
var tcs = new TaskCompletionSource<RunResult>();
|
||||
|
||||
var (service, _) = CreateService((_, _, _, _, _, _) => tcs.Task);
|
||||
var (service, _) = CreateService((_, _, _, _, _) => tcs.Task);
|
||||
|
||||
var task = await SeedQueuedTask(listId);
|
||||
await service.RunNow(task.Id);
|
||||
@@ -235,23 +279,23 @@ public sealed class QueueServiceTests : IDisposable
|
||||
|
||||
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;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
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" }));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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