61 Commits

Author SHA1 Message Date
33fedc7e26 Merge pull request 'feat/efcore-migration' (#3) from feat/efcore-migration into main
All checks were successful
Release / release (push) Successful in 30s
Reviewed-on: #3
2026-04-16 07:38:59 +00:00
mika kuns
4ca48044db chore: add design-time.db to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:53 +02:00
mika kuns
611454df1e fix(data): address code review findings
- Fix sort order regression in GetByListIdAsync (was descending, should be ascending)
- Restore WAL mode pragma (was silently dropped in EF migration)
- Add existing-DB compatibility shim in MigrateAndConfigure (baselines InitialCreate
  migration for databases created by the old schema.sql)
- Remove dead AddDbContext/AddScoped registrations from Worker (only IDbContextFactory
  is used by singleton consumers)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:10:35 +02:00
mika kuns
8d61b05179 docs: update CLAUDE.md files for EF Core migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 09:03:15 +02:00
mika kuns
7d0ca45a60 refactor(installer): switch InitDatabaseStep to EF Core migrations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:01:50 +02:00
mika kuns
36484ed45a feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory
Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer
with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos
changed from AddSingleton to AddScoped.

All singleton services (QueueService, StaleTaskRecovery, WorktreeManager,
TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel,
TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext>
and create short-lived contexts per operation.

Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer;
all test classes create contexts via DbFixture.CreateContext().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:24 +02:00
mika kuns
b7be52a623 chore(data): remove raw ADO.NET infrastructure, add EF migration and design-time factory
Delete SqliteConnectionFactory, SchemaInitializer, and schema.sql.
Fix ValueConverter lambdas in entity configurations (no throw-expressions
in expression trees). Add IDesignTimeDbContextFactory for dotnet-ef tooling.
Generate InitialCreate migration with seed data for agent/manual tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:59:06 +02:00
mika kuns
34ca1b018f feat(data): rewrite all repositories to use EF Core ClaudeDoDbContext
Replace raw ADO.NET implementations with EF Core LINQ queries and
ExecuteUpdate/ExecuteDelete for bulk operations. TaskRepository preserves
FlipAllRunningToFailedAsync(reason) signature and keeps raw SQL for the
atomic queue claim (UPDATE...RETURNING). GetByListAsync alias kept for
backwards compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:58:57 +02:00
mika kuns
51a5dcbb73 feat(data): add ClaudeDoDbContext with Fluent API configurations 2026-04-16 08:37:37 +02:00
mika kuns
f8f13865d2 feat(data): add navigation properties to all entity models 2026-04-16 08:36:22 +02:00
mika kuns
a064865417 chore(data): swap Microsoft.Data.Sqlite for EF Core packages 2026-04-16 08:35:40 +02:00
mika kuns
9236ca6d45 docs(data): add EF Core migration implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:32:18 +02:00
mika kuns
9e1f1370bb docs(data): add EF Core migration design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:20:33 +02:00
Mika Kuns
3b1f148122 feat(branding): add app and installer icons
All checks were successful
Release / release (push) Successful in 27s
- app: ClaudeTask.ico wired as ApplicationIcon and MainWindow.Icon
  via avares URI
- installer: ClaudeTaskSetup.ico wired as ApplicationIcon and embedded
  as a WPF Resource so WizardWindow and SettingsWindow can reference it
  via pack URI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:28:15 +02:00
Mika Kuns
2b3fe02d8c fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource
  so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags;
  old subtask PropertyChanged handlers are torn down before Clear
- task detail vm: async void event handlers (OnTaskUpdated,
  OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch
  so thrown exceptions can't crash the Avalonia sync context
- task detail vm: Clear cancels/disposes the load CTS so a late-arriving
  LoadAsync can't resurrect detail state after deselect
- app: DisposeAsync the ServiceProvider in a finally after the classic
  desktop lifetime ends, so WorkerClient.DisposeAsync runs and the
  SignalR connection closes cleanly instead of being abandoned

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:45 +02:00
Mika Kuns
d3b85f2234 fix(worker): address concurrency, cancellation, and resource issues
- claude process: run stdout/stderr reads without ct; rely on
  kill-on-cancel closing the pipes to unblock them — previously
  ReadLineAsync(ct) could hang, stalling task slots and shutdown
- task runner: terminal db writes (task_runs, MarkDone, MarkFailed,
  SetLogPath) now use CancellationToken.None; RunOnceAsync catches
  OCE and finalizes the run row so ContinueAsync can resume
- task repository: GetNextQueuedAgentTaskAsync is now a single
  UPDATE ... RETURNING statement — closes TOCTOU window where two
  loop iterations could dispatch the same queued task
- queue service: dispose CancellationTokenSource in slot-completion
  ContinueWith to stop leaking wait handles
- git service: register ct.Kill(processTree), drain reads without ct,
  always reap via WaitForExitAsync(None) — no more git zombies on
  cancelled worktree ops
- worktree manager: branch name uses full task id (dashes stripped)
  instead of 8-char prefix, eliminating collision risk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:27:18 +02:00
Mika Kuns
fc9029de97 fix(installer): wait for prior service registration to clear before create
After `sc delete`, the service stays in "marked for deletion" state until
every open handle (services.msc, Task Manager Services tab, Event Viewer,
prior sc query process) is closed. The installer used to immediately call
`sc create` and hit a silent hang / confusing "specified service has been
marked for deletion" error.

Poll `sc query` for up to 30s after delete; if the service is still
registered past that, fail with actionable guidance (close the offending
console or reboot). Also translate exit 1072 from `sc create` into the
same human-readable hint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:54:36 +02:00
Mika Kuns
1c764dae3f fix(installer): publish framework-dependent single-file
All checks were successful
Release / release (push) Successful in 27s
Self-contained single-file bundle was crashing at startup with 0xc0000005
in the apphost bootstrap (COR_E_EXECUTIONENGINE), because the Linux Gitea
runner doesn't carry the Microsoft.WindowsDesktop.App runtime pack — the
resulting bundle was missing WPF runtime bits. Disabling compression alone
didn't resolve it.

Switch to framework-dependent single-file: target machines need the .NET 8
Desktop Runtime (x64) installed but the bundle is ~2 MB instead of ~140 MB
and starts reliably. Keep IncludeAllContentForSelfExtract=true so native
deps (e_sqlite3) extract to a temp dir on first run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:11:27 +02:00
Mika Kuns
cfec3297a4 fix(installer): disable single-file compression to prevent WPF startup AV
All checks were successful
Release / release (push) Successful in 30s
Published installer was crashing at launch with 0xc0000005 (access violation)
and exit code 0x80131506 (COR_E_EXECUTIONENGINE), faulting inside the exe's
own bundle-extractor bootstrap before any managed code ran. This is a known
interaction between WPF, single-file publish, and EnableCompressionInSingleFile.

Disable compression (exe grows ~30% but startup becomes reliable).
IncludeAllContentForSelfExtract stays true — WPF still needs on-disk
extraction for XAML resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:48:06 +02:00
Mika Kuns
6e1d64b489 Change sdk Version
All checks were successful
Release / release (push) Successful in 39s
2026-04-15 14:23:26 +02:00
Mika Kuns
f599f8d0af fix(installer,worker): service hosting, dark theme, uninstall polish
Some checks failed
Release / release (push) Failing after 0s
Worker:
- Wire UseWindowsService + Microsoft.Extensions.Hosting.WindowsServices so
  SCM's Service Control Protocol handshake succeeds. Previously the binary
  exited immediately under sc start, leaving the service registered but
  never running.

Installer:
- Pin SDK to .NET 9 (global.json) — SDK 10 dropped win-arm from its RID
  graph, breaking restore of the WPF project; .NET 9 keeps win-arm AND
  understands the .slnx solution format.
- Force SelfContained=true and default RID=win-x64 when PublishSingleFile
  is set, so Rider Publish and CLI produce the same bundle.
- Dark theme: set Background/Foreground explicitly on WizardWindow and
  SettingsWindow roots (WPF implicit styles don't cascade to derived
  Window types). Custom ComboBox template + ComboBoxItem style so
  dropdowns honour the dark palette instead of system defaults.
- Throttle download progress to one report per MB and overwrite the same
  UI line (\r prefix marker) instead of appending per chunk.
- Register ClaudeDo in HKLM\...\Uninstall so it appears in Apps & Features.
  Copy installer into InstallDir\uninstaller\ for the UninstallString, and
  schedule a cmd.exe trampoline to handle the self-delete case when
  Apps & Features launches the copy from inside the install dir.
- Treat sc.exe stop exit 1062 (ERROR_SERVICE_NOT_ACTIVE) as success.
- Delete the uninstall registry key during UninstallRunner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:19:09 +02:00
Mika Kuns
9b928c6217 fix(installer): set EnableWindowsTargeting so Linux Gitea runners can publish
All checks were successful
Release / release (push) Successful in 40s
The release workflow runs on a Linux container; building net8.0-windows +
UseWPF=true requires this opt-in property since .NET 8. No-op on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:39:23 +02:00
c9e38aef88 Merge pull request 'feat/ui-improvements' (#1) from feat/ui-improvements into main
Some checks failed
Release / release (push) Failing after 19s
Reviewed-on: #1
2026-04-15 09:28:58 +00:00
66843d242b Merge branch 'main' into feat/ui-improvements 2026-04-15 09:28:18 +00:00
6afe5959ca Merge pull request 'feat/release-workflow' (#2) from feat/release-workflow into main
Reviewed-on: #2
2026-04-15 09:27:53 +00:00
b623651a5d Merge branch 'main' into feat/release-workflow 2026-04-15 09:27:21 +00:00
Mika Kuns
6b1b920149 feat(installer): download-mode rewrite + Gitea Releases pipeline
Rewrites ClaudeDo.Installer to fetch prebuilt binaries from
git.kuns.dev/releases/ClaudeDo instead of building from source.

- Async InstallModeDetector: FreshInstall / Update / Config
- DownloadAndExtractStep with SHA256 verify + scratch-dir extract
- UninstallRunner: stop-service / delete / full ~/.todo-app removal
  with path guard + partial-failure reporting
- Config view: Save / Repair / Uninstall buttons
- Self-contained single-file publish for the installer itself
- 29 xUnit tests in new ClaudeDo.Installer.Tests project

Spec: docs/superpowers/specs/2026-04-15-installer-download-mode-design.md
Plan: docs/superpowers/plans/2026-04-15-installer-download-mode.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:22:01 +02:00
Mika Kuns
b7a8d78d4a chore(installer): remove orphaned InstallerService DI registration 2026-04-15 11:10:24 +02:00
Mika Kuns
b5455a1965 feat(installer): mode-aware wizard page list + Update-mode step pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:07:03 +02:00
Mika Kuns
5d42438a72 fix(installer): UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:56:39 +02:00
Mika Kuns
2898bec314 feat(installer): Config view — Save/Repair/Uninstall commands + footer buttons 2026-04-15 10:31:24 +02:00
Mika Kuns
ac38ea8c34 feat(installer): add UninstallRunner (service + shortcuts + dirs) 2026-04-15 10:29:25 +02:00
Mika Kuns
8d2f7e9907 fix(installer): null-defensive WelcomePage heading + guard unreachable modes 2026-04-15 10:27:30 +02:00
Mika Kuns
da1fe2109a feat(installer): rewrite WelcomePage for download-mode + update heading
Removes SourceDirectory field (no longer in InstallContext), adds
dynamic Heading/Subheading/InstallDirEditable for FreshInstall vs Update
mode, and updates XAML to match sibling page style.
2026-04-15 10:14:38 +02:00
Mika Kuns
5e432a4a27 fix(installer): fall back to Config on detection timeout when install.json exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:10:02 +02:00
Mika Kuns
01c29bb6f6 feat(installer): async mode detection + mode-aware DI wiring 2026-04-15 10:01:20 +02:00
Mika Kuns
12e532718c fix(installer): wrap WriteInstallManifestStep I/O in try/catch like sibling steps 2026-04-15 09:58:16 +02:00
Mika Kuns
fe913ae5ef build(installer): add single-file self-contained publish properties 2026-04-15 09:55:53 +02:00
Mika Kuns
4fab0481c4 refactor(installer): replace SourceDirectory with Mode/Version fields in InstallContext 2026-04-15 09:54:57 +02:00
Mika Kuns
0989176127 refactor(installer): remove source-build steps (replaced by DownloadAndExtractStep) 2026-04-15 09:54:22 +02:00
Mika Kuns
548251841f feat(installer): add WriteInstallManifestStep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:48:29 +02:00
Mika Kuns
ea32a74baa fix(installer): harden DownloadAndExtractStep per review 2026-04-15 09:43:27 +02:00
Mika Kuns
c1e330164e feat(installer): add DownloadAndExtractStep with SHA256 verify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:37:04 +02:00
Mika Kuns
5b4af29420 fix(installer): check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING 2026-04-15 09:32:26 +02:00
Mika Kuns
d87de152e0 feat(installer): add Stop/StartServiceStep sc.exe wrappers 2026-04-15 09:27:54 +02:00
Mika Kuns
b4dc9509cb test(installer): pin 'unparseable version = Config' behavior + document IsNewer limits 2026-04-15 09:26:18 +02:00
Mika Kuns
97fb215ce6 feat(installer): replace sync ModeDetector with async InstallModeDetector
Placeholder edit to App.xaml.cs to keep the project building until Task 11
wires the new async detector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:19:16 +02:00
Mika Kuns
83d7058b32 fix(installer): propagate cancellation + defensive asset parsing in ReleaseClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:15:56 +02:00
Mika Kuns
5603fd458d feat(installer): add IReleaseClient + Gitea ReleaseClient 2026-04-15 09:10:02 +02:00
Mika Kuns
d0c0e2ce1f feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser) 2026-04-15 09:03:08 +02:00
Mika Kuns
2fc6924dcb test(installer): add InstallManifest wrong-shape json test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:59:51 +02:00
Mika Kuns
921e626208 feat(installer): add InstallManifest + json-backed store 2026-04-15 08:53:52 +02:00
Mika Kuns
c23ed94817 test(installer): address review — drop UseWPF, thread-safe FakeHttpMessageHandler 2026-04-15 08:51:12 +02:00
Mika Kuns
2d34afb2e5 test(installer): scaffold ClaudeDo.Installer.Tests project 2026-04-15 08:46:17 +02:00
Mika Kuns
c0bd46542a docs(installer): add download-mode implementation plan
17-task TDD plan for rewriting the installer to fetch binaries from
releases/ClaudeDo on git.kuns.dev. Covers InstallManifest, ReleaseClient,
InstallModeDetector, DownloadAndExtractStep, Config/Repair/Uninstall,
and the publish-time single-file self-contained settings.

Workflow file is out of scope (handled by VPS Claude).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:37:07 +02:00
7119c8474e ci(release): use system zip now that it's installed on the runner 2026-04-15 06:33:50 +00:00
aea09098e6 feat(ci): add Gitea Actions release workflow
Builds App + Worker + Installer for win-x64 self-contained on v* tag push,
bundles into ClaudeDo-<version>-win-x64.zip (app + worker),
renames installer to ClaudeDo.Installer-<version>.exe,
writes sha256 checksums.txt, then creates a Gitea Release on
releases/ClaudeDo and attaches all three assets.

Uses the workflow-scoped GITEA_TOKEN; no PAT required.
Host-mode runner (ubuntu-latest:host) with installed .NET 8 at
/home/mika/.dotnet. Uses python3 -m zipfile because the host
runner has no zip CLI, and git clone instead of actions/checkout
because DEFAULT_ACTIONS_URL=self has no local checkout mirror.
2026-04-15 06:31:50 +00:00
Mika Kuns
0498fbae47 docs(installer): finalize decisions — self-contained, auto-check, full uninstall
- App + Worker now self-contained (zero .NET runtime dep on target)
- Collapse Manage mode into "update check -> Config view" on every
  subsequent launch; Repair + Uninstall become buttons in Config
- Uninstall removes {InstallDir} and ~/.todo-app in full (no prompt
  to keep data) — matches user's stated intent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:21:12 +02:00
Mika Kuns
43a10cff95 docs(installer): pin release target to releases/ClaudeDo
VPS confirmed the releases/ org is world-readable without auth; the
ClaudeDo source already lives at git.kuns.dev/releases/ClaudeDo, so the
workflow uses the built-in gitea.token (no cross-org PAT needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:14:28 +02:00
Mika Kuns
bd7d5940a2 docs(installer): add download-mode + Gitea Releases design spec
Design for rewriting the installer to fetch prebuilt binaries from Gitea
Releases on git.kuns.dev instead of building from source. Covers the
Actions workflow, release artifact layout, install.json marker file,
Install/Update/Manage mode detection, and the new DownloadAndExtractStep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:03:40 +02:00
CubeGameLP
78831b2263 feat(installer): add WPF installer/configurator project
Standalone WPF app (ClaudeDo.Installer) that handles full installation
and ongoing configuration of ClaudeDo. Two modes: wizard for first run,
tabbed settings panel for subsequent launches. Page-based extensibility
via IInstallerPage interface — adding new config sections requires only
one new class.

Install pipeline: dotnet publish, deploy binaries, write configs, init
DB (via SchemaInitializer from ClaudeDo.Data), register Windows Service,
create shortcuts. Dark theme matching the Avalonia app (forest teal accent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:01:03 +02:00
126 changed files with 10872 additions and 1188 deletions

View File

@@ -0,0 +1,174 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeDo
steps:
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building version: $VERSION (tag: $TAG)"
- name: Prepare workspace
id: ws
run: |
set -euo pipefail
WORK="$(mktemp -d -t claudedo-release-XXXXXX)"
echo "dir=$WORK" >> "$GITHUB_OUTPUT"
echo "Workspace: $WORK"
- name: Checkout tag
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Publish ClaudeDo.App (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.App/ClaudeDo.App.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/app
- name: Publish ClaudeDo.Worker (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
dotnet publish src/ClaudeDo.Worker/ClaudeDo.Worker.csproj \
-c Release -r win-x64 --self-contained true \
/p:Version=$VERSION -o out/worker
- name: Publish ClaudeDo.Installer (win-x64, single-file, framework-dependent)
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd "$WORK/src"
# Framework-dependent — WPF runtime pack isn't distributed on Linux SDK;
# the previous self-contained bundle crashed at startup (apphost AV).
# Target machines need .NET 8 Desktop Runtime (x64).
dotnet publish src/ClaudeDo.Installer/ClaudeDo.Installer.csproj \
-c Release -r win-x64 --self-contained false \
/p:Version=$VERSION /p:PublishSingleFile=true \
-o out/installer
- name: Package assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
cd "$WORK/src"
mkdir -p assets
# 1) App + Worker bundle (top-level dirs /app and /worker)
rm -rf bundle
mkdir -p bundle
cp -r out/app bundle/app
cp -r out/worker bundle/worker
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
# 2) Installer single-file exe (renamed)
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2
exit 1
fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
# 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
> checksums.txt )
echo "--- assets ---"
ls -la assets
- name: Create Gitea Release
id: release
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Release creation failed" >&2
echo "$RESP" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "Created release id=$RELEASE_ID for tag=$TAG"
- name: Upload release assets
env:
WORK: ${{ steps.ws.outputs.dir }}
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd "$WORK/src/assets"
for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
"checksums.txt"
do
echo "Uploading: $f"
curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${f}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
> /dev/null
done
echo "All assets uploaded."
- name: Cleanup workspace
if: always()
env:
WORK: ${{ steps.ws.outputs.dir }}
run: |
rm -rf "$WORK" || true

1
.gitignore vendored
View File

@@ -61,3 +61,4 @@ Desktop.ini
*.log *.log
*.tmp *.tmp
*.bak *.bak
design-time.db

View File

@@ -15,7 +15,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
## Tech Stack ## Tech Stack
- .NET 8.0, Avalonia 12.0.0 (Fluent theme) - .NET 8.0, Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM - SQLite (WAL mode) via Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite)
- SignalR for real-time IPC - SignalR for real-time IPC
- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`) - CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
- Git worktrees for task isolation - Git worktrees for task isolation
@@ -27,12 +27,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- Worker config: `~/.todo-app/worker.config.json` - Worker config: `~/.todo-app/worker.config.json`
- Logs: `~/.todo-app/logs/` - Logs: `~/.todo-app/logs/`
- Worktrees: configured per worker (sibling or central strategy) - Worktrees: configured per worker (sibling or central strategy)
- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
## Conventions ## Conventions
- Repository pattern — each entity has its own async repository - Repository pattern — each entity has its own async repository
- All data operations are async with CancellationToken support - All data operations are async with CancellationToken support
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Manual | Queued -> Running -> Done | Failed - Task status flow: Manual | Queued -> Running -> Done | Failed
- Worktree state flow: Active -> Merged | Discarded | Kept - Worktree state flow: Active -> Merged | Discarded | Kept
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup - Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup

View File

@@ -4,9 +4,11 @@
<Project Path="src/ClaudeDo.Data/ClaudeDo.Data.csproj" /> <Project Path="src/ClaudeDo.Data/ClaudeDo.Data.csproj" />
<Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" /> <Project Path="src/ClaudeDo.Ui/ClaudeDo.Ui.csproj" />
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" /> <Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" /> <Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" /> <Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
# Installer: Download-Mode + Gitea Releases
Date: 2026-04-15
Status: Design — awaiting implementation plan
## Goal
Turn `ClaudeDo.Installer` into a self-contained tool that any user can run on
any Windows machine to install, update, reconfigure, repair, or uninstall
ClaudeDo. The installer pulls prebuilt binaries from a Gitea release on
`git.kuns.dev` instead of building from source.
End-user experience:
1. Download `ClaudeDo.Installer-<version>.exe` from the releases page.
2. Run it.
3. Done — no .NET SDK, no source checkout, no manual steps.
## Non-Goals
- Code signing the installer or the app binaries (future concern).
- Cross-platform installs (Windows-only, same as today).
- In-app update notifications (the installer handles updates when run; the app
does not self-update).
- Arbitrary-version selection UI. Installer always targets "latest" release.
- A package-manager listing (winget/Chocolatey/Scoop). Future, separate spec.
## Current State (2026-04-15)
The existing installer (`src/ClaudeDo.Installer/`) is a WPF wizard that only
works from inside a source checkout on a machine with the .NET SDK installed:
- `PublishAppStep` runs `dotnet publish src/ClaudeDo.App/...`
- `PublishWorkerStep` runs `dotnet publish src/ClaudeDo.Worker/...`
- `DeployBinariesStep` copies `bin/Release/.../publish` into the install dir
- Subsequent steps (`WriteConfigStep`, `InitDatabaseStep`,
`CreateShortcutsStep`, `RegisterServiceStep`) are fine to keep.
The installer also contains a partial "Settings" window
(`Views/SettingsWindow.xaml`, `Views/SettingsViewModel.cs`) — that wiring is
reused for the Config view shown on subsequent launches (see Mode detection
below).
## High-Level Design
Two pieces, each small:
**1) A Gitea Actions workflow** that, on every `v*` tag push, builds the App,
Worker, and Installer; packages them; and creates a Gitea Release on the
public repo at `git.kuns.dev/releases/ClaudeDo`.
The `releases/` org on the Gitea instance is world-readable without auth;
private work (including the source repo, if you want) lives under `kuns/*`
which is never public. The installer only needs to hit `releases/ClaudeDo`.
**2) An installer rewrite** that replaces the three publish/deploy steps with
a single `DownloadAndExtractStep`, detects existing installs via a marker
file, and on subsequent launches checks the Gitea API for updates before
deciding whether to show the Update flow or jump straight to the Config view.
## Release Artifacts
Each `v*` tag produces a Gitea Release with three assets:
```
ClaudeDo-<version>-win-x64.zip # contains /app and /worker subdirs
ClaudeDo.Installer-<version>.exe # self-contained installer (no .NET needed)
checksums.txt # SHA256 of the above
```
Decisions:
- **One combined app+worker zip** (not two separate). Reasons: one download,
one extract, guaranteed version-locked pair.
- **Self-contained installer exe** — user does not need .NET installed.
- **App + Worker: self-contained** (`--self-contained true`, `-r win-x64`).
Zero runtime dependency on the target machine, at the cost of a larger
download (~100 MB combined zip). Decision: acceptable trade-off — the
installer is one-click, not per-user-problem-to-debug.
- **Checksums file** — plain text, one line per asset (`<sha256> <filename>`),
verified by installer before extract.
The "latest installer exe" URL is stable:
```
https://git.kuns.dev/releases/ClaudeDo/releases/latest/download/ClaudeDo.Installer-<version>.exe
```
(Gitea also exposes `/releases/download/<tag>/<filename>` for specific
versions.)
## Gitea Actions Workflow
File: `.gitea/workflows/release.yml`
- **Trigger:** `push` on tags matching `v*`
- **Runner:** Linux container with .NET 8 SDK (`dotnet publish -r win-x64`
works cross-platform). The installer itself requires Windows to run, but
`dotnet publish` can target `win-x64` from Linux.
- **Steps:**
1. Checkout
2. Setup .NET 8 SDK
3. Derive version from tag (`${{ gitea.ref_name }}` without the `v` prefix)
4. `dotnet publish src/ClaudeDo.App -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/app`
5. `dotnet publish src/ClaudeDo.Worker -c Release -r win-x64 --self-contained true /p:Version=$VERSION -o out/worker`
6. `dotnet publish src/ClaudeDo.Installer -c Release -r win-x64 --self-contained true /p:Version=$VERSION /p:PublishSingleFile=true -o out/installer`
7. Zip `out/app` + `out/worker` as `ClaudeDo-<version>-win-x64.zip` with
`app/` and `worker/` as top-level dirs
8. Copy `out/installer/ClaudeDo.Installer.exe` to
`ClaudeDo.Installer-<version>.exe`
9. Generate `checksums.txt` (`sha256sum` both files)
10. Create release via Gitea API using the built-in `${{ gitea.token }}`
(this token has repo write scope automatically on Actions runs). Release
name = tag name. Release notes = `git log` summary between previous tag
and this one (nice-to-have).
The workflow needs **no custom secrets**`gitea.token` is sufficient for
creating releases on its own repo.
## Installer Changes
### New: `install.json` marker file
Written at the end of every successful install or update to
`{InstallDir}/install.json`:
```json
{
"version": "0.2.0",
"installDir": "C:\\Program Files\\ClaudeDo",
"workerDir": "C:\\Program Files\\ClaudeDo\\worker",
"installedAt": "2026-04-15T12:34:56Z"
}
```
The installer reads this on startup (from the default install dir, or a
path supplied via CLI arg) to decide which mode to run in.
### Launch flow (`InstallModeDetector`)
On every launch, the installer checks for `install.json` first:
```
install.json absent?
-> Install mode: Welcome -> Paths -> UiSettings -> Service -> Install
(writes install.json at the end)
install.json present?
-> Query https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
(short timeout; if offline, treat as "no update available")
latest.tag_name > installed.version
-> Update mode: Welcome ("Update v0.1.0 -> v0.2.0, Update / Later")
If user accepts -> Install steps (download + swap service)
If user declines -> fall through to Config view
latest.tag_name <= installed.version (or API unreachable)
-> Config view: directly open Paths/UiSettings/Service tabs,
prefilled from existing ~/.todo-app/*.json.
Action buttons: Save · Repair · Uninstall.
```
Key properties:
- **First run = wizard**, as today — no behavior change for new users.
- **Every subsequent run = update check first**, then either offer update or
drop straight into Config. No "Manage page" with a menu of actions — the
Config view *is* the default, and Repair/Uninstall are buttons on it.
- **Offline / API error = not fatal**: if the release endpoint can't be
reached, the installer silently skips the update check and opens Config.
The user is never blocked from managing an existing install by a network
issue.
- **Downgrade** (installed version > latest) is treated the same as "no
update available" — we don't ever offer a downgrade.
The installer's own version (shown for reference in Config) comes from its
assembly (`AssemblyInformationalVersion`), set by the workflow's
`/p:Version=$VERSION`. The *installed* version comes from `install.json`.
### New step: `DownloadAndExtractStep`
Replaces `PublishAppStep`, `PublishWorkerStep`, `DeployBinariesStep`.
```
1. GET https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest
Parse tag_name and asset URLs for:
- ClaudeDo-<ver>-win-x64.zip
- checksums.txt
2. Download both to %TEMP%\ClaudeDo-install-<guid>\
3. Parse checksums.txt, verify SHA256 of the zip. Fail hard if mismatch.
4. (Update mode only) Stop Worker service via sc.exe stop ClaudeDoWorker.
Wait up to 30s for it to actually stop. If it won't stop, fail.
5. (Update mode only) Delete contents of {InstallDir}/app and
{InstallDir}/worker, but leave the directories and install.json in place.
6. Extract zip: /app -> {InstallDir}/app, /worker -> {InstallDir}/worker.
7. (Update mode only) Start service again via sc.exe start ClaudeDoWorker.
8. Progress is reported via IProgress<string> — the UI already shows it.
```
Config files (`~/.todo-app/*.json`) and DB (`~/.todo-app/todo.db`) live
outside `InstallDir` and are never touched by this step — updates are
naturally non-destructive.
### Update mode — which steps run
- **Yes:** `DownloadAndExtractStep`
- **No:** `WriteConfigStep` (user already has config — we don't overwrite)
- **No:** `InitDatabaseStep` (DB exists)
- **No:** `CreateShortcutsStep` (already there; Repair can re-run this)
- **Conditional:** `RegisterServiceStep` only if service is not currently
registered (covers someone who unregistered it manually)
### Config view — actions
- **Save** (primary): writes the Paths / UiSettings / Service fields to
`~/.todo-app/*.json`. If worker config changed, prompts "Restart service?"
and calls `sc stop` / `sc start` if accepted. No download.
- **Repair:** re-download + extract (same as Update flow), re-create
shortcuts, re-register service. Leaves config/DB alone. Confirmation
dialog before starting.
- **Uninstall:** confirmation dialog ("This removes ClaudeDo *and* all of
your tasks, config, and database. Type UNINSTALL to confirm."). On
confirm:
1. Stop + unregister service (`sc stop`, `sc delete ClaudeDoWorker`)
2. Remove Start Menu / Desktop shortcuts
3. Delete `{InstallDir}` (including `install.json`)
4. Delete `~/.todo-app` in full (config + DB + logs)
5. Exit
Everything is removed. No "keep my data" option — that was explicitly
declined in the design discussion.
### Files to add
```
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
src/ClaudeDo.Installer/Core/ReleaseClient.cs // Gitea API + downloads
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
src/ClaudeDo.Installer/Core/InstallManifest.cs // read/write install.json
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
src/ClaudeDo.Installer/Steps/StopServiceStep.cs // used in Update+Uninstall
src/ClaudeDo.Installer/Steps/StartServiceStep.cs // used in Update+Repair
.gitea/workflows/release.yml
```
### Files to remove
```
src/ClaudeDo.Installer/Steps/PublishAppStep.cs
src/ClaudeDo.Installer/Steps/PublishWorkerStep.cs
src/ClaudeDo.Installer/Steps/DeployBinariesStep.cs
```
### Files to update
- `Core/InstallerService.cs` — mode-aware step list
- `Core/InstallContext.cs` — add `Version`, `Mode`, `IsFirstInstall` fields
- `Pages/WelcomePage/*` — content + buttons depend on mode
- `Views/WizardViewModel.cs` — route pages based on mode
- `Core/PageResolver.cs` — register new/renamed pages
- `ClaudeDo.Installer.csproj` — add `PublishSingleFile`, `SelfContained`
properties (only active when published)
## Failure Modes & Recovery
| Failure | Behavior |
|---------------------------------------|-------------------------------------------------------|
| No network / Gitea unreachable | Step fails with clear message + retry button |
| API returns no releases yet | "No release available — publish a tag first" |
| Checksum mismatch | Step fails, temp files deleted, user prompted retry |
| Zip extraction fails mid-way (update) | InstallDir is left partially empty — user re-runs |
| Service won't stop | Fail before extract; nothing destructive has happened |
| User cancels mid-download | Temp dir cleaned up; install state unchanged |
For safety, the `DownloadAndExtractStep` always downloads + verifies
**before** it deletes the old binaries. An aborted download cannot leave
an install in a half-deleted state.
## Security
- All downloads over HTTPS from a pinned host (`git.kuns.dev`).
- SHA256 verification before extract (protects against partial downloads and
tampered caches on the wire; not a substitute for code signing).
- No tokens shipped in the installer — repo is public.
- Worker service runs under the same account as today (no change).
## Decisions to Revisit
1. **Release notes content.** Auto-generated `git log` summary vs manual
notes in the tag message vs empty. Start empty; revisit when there are
enough releases to care.
2. **Signed installer.** Out of scope for v1. Users will see a SmartScreen
warning the first time. Note this in the README.
3. **Installer distribution page.** A simple `README.md` badge or a pinned
"Latest release" link on the Gitea repo home is enough for v1.
## Success Criteria
- On a fresh Windows VM with **no source checkout, no .NET runtime, and no
.NET SDK**:
1. Download `ClaudeDo.Installer-<ver>.exe`.
2. Run it.
3. Complete the wizard.
4. ClaudeDo App launches, Worker service is running, a task can be created
and picked up.
- Running the same installer a second time, with no new release published,
opens directly in the Config view after a quick update check.
- Publishing a new tag, then running the installer on the existing install,
offers the update; accepting performs it without touching `~/.todo-app/todo.db`
or the config JSONs.
- Uninstall leaves no trace: `{InstallDir}` gone, `~/.todo-app` gone, service
unregistered, shortcuts removed.
- The entire release pipeline runs on `git.kuns.dev` with no manual steps
beyond `git tag vX.Y.Z && git push --tags`.

View File

@@ -0,0 +1,253 @@
# EF Core Migration Design
Replace the raw ADO.NET / Microsoft.Data.Sqlite data layer with Entity Framework Core and LINQ queries.
## Motivation
- Developer ergonomics: raw SQL is tedious to write and maintain; LINQ enables faster iteration.
- Maintainability: the ad-hoc migration approach (ALTER TABLE with error-code catching) and manual DBNull/enum mapping are a liability as the schema grows. EF Core provides proper migration versioning, value converters, and change tracking.
## Decision Summary
| Decision | Choice |
|---|---|
| Approach | Big bang — rewrite all 6 repositories at once |
| Migration strategy | Fresh start — EF Core owns the schema, drop schema.sql |
| DbContext sharing | Single shared `ClaudeDoDbContext` in ClaudeDo.Data |
| Configuration style | Fluent API only, clean POCO models |
| Atomic queue claim | Kept as `FromSqlRaw` — not expressible in LINQ |
---
## 1. DbContext and Entity Configuration
### ClaudeDoDbContext
A single `ClaudeDoDbContext` in `ClaudeDo.Data` with DbSets for all entities:
```csharp
public class ClaudeDoDbContext : DbContext
{
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
}
```
### Entity-to-Table Mapping
| Entity | Table | Key | Notes |
|---|---|---|---|
| `TaskEntity` | `tasks` | `Id` (TEXT) | Nav to List, Tags, Worktree, Runs, Subtasks |
| `ListEntity` | `lists` | `Id` (TEXT) | Nav to Tasks, Tags, Config |
| `TagEntity` | `tags` | `Id` (INTEGER auto) | Nav to Lists, Tasks (both M:N) |
| `ListConfigEntity` | `list_config` | `ListId` (TEXT) | 1:1 owned by List |
| `WorktreeEntity` | `worktrees` | `TaskId` (TEXT) | 1:1 owned by Task |
| `TaskRunEntity` | `task_runs` | `Id` (TEXT) | FK to Task |
| `SubtaskEntity` | `subtasks` | `Id` (TEXT) | FK to Task |
### Navigation Properties Added to Models
```csharp
// TaskEntity gains:
public ListEntity List { get; set; }
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; }
public ICollection<TaskRunEntity> Runs { get; set; }
public ICollection<SubtaskEntity> Subtasks { get; set; }
// ListEntity gains:
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
public ICollection<TagEntity> Tags { get; set; }
// TagEntity gains:
public ICollection<ListEntity> Lists { get; set; }
public ICollection<TaskEntity> Tasks { get; set; }
```
### Enum Handling
EF Core `ValueConverter<TEnum, string>` for `TaskStatus` and `WorktreeState`, storing the same lowercase strings (`"manual"`, `"active"`, etc.) for database compatibility. The `ToDb`/`FromDb` methods in repositories are removed.
### Junction Tables
`list_tags` and `task_tags` are configured as implicit join tables via `.UsingEntity()` in Fluent API — no explicit junction entity classes needed.
### Fluent Configuration
Each entity gets its own `IEntityTypeConfiguration<T>` class in a `Configuration/` folder within `ClaudeDo.Data`.
---
## 2. Migration Strategy
### Fresh Start
- `schema.sql` and `SchemaInitializer` are deleted.
- An initial EF Core migration (`InitialCreate`) is generated from the DbContext model, producing the full schema (all 8 tables, indexes, foreign keys, check constraints).
- EF's `__EFMigrationsHistory` table tracks applied migrations.
### Startup
Both App and Worker call `context.Database.Migrate()` at startup instead of `SchemaInitializer.Apply()`. This is idempotent.
### Existing Database Compatibility
For users who already have a database created by `schema.sql`, the initial migration must handle the schema already existing. On startup, if the `lists` table exists but `__EFMigrationsHistory` does not, insert the initial migration record into `__EFMigrationsHistory` so EF skips it.
### Seed Data
The `"agent"` and `"manual"` tags move into `OnModelCreating` via `HasData()`:
```csharp
modelBuilder.Entity<TagEntity>().HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
```
### Ad-hoc Migrations Removed
The 3 manual `ALTER TABLE` statements (model, system_prompt, agent_path on tasks) become part of the initial migration since they're already in the model. The manual `ApplyMigrations()` method is deleted.
---
## 3. Repository Rewrite
All 6 repositories are rewritten to use `ClaudeDoDbContext` and LINQ.
### Per-Repository Changes
| Repository | After EF Core |
|---|---|
| `TagRepository` | LINQ queries. `GetOrCreateAsync` uses `FirstOrDefaultAsync` + `Add` + `SaveChangesAsync`. Static `SqliteConnection` overload removed. |
| `SubtaskRepository` | Straightforward LINQ CRUD, `.OrderBy(s => s.OrderNum)`. |
| `WorktreeRepository` | LINQ CRUD. State update becomes property set + `SaveChangesAsync`. |
| `ListRepository` | LINQ CRUD. Tag management via `.Tags` navigation property. Config upsert via `List.Config` navigation. |
| `TaskRunRepository` | LINQ CRUD. Latest = `.OrderByDescending(r => r.RunNumber).FirstOrDefaultAsync()`. |
| `TaskRepository` | See special cases below. |
### TaskRepository Special Cases
**Atomic queue claim** (`GetNextQueuedAgentTaskAsync`): kept as `FromSqlRaw` / `ExecuteSqlRawAsync`. The `UPDATE ... WHERE id = (SELECT ...) RETURNING` is not expressible in LINQ and the atomicity guarantee matters.
**Effective tags** (`GetEffectiveTagsAsync`): LINQ via navigation properties:
```csharp
var taskTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
```
**FlipAllRunningToFailed**: EF Core 7+ bulk update:
```csharp
await context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Failed), ct);
```
**Status transitions** (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`): property updates + `SaveChangesAsync`.
### Removed Code
- `SqliteConnectionFactory.cs`
- `SchemaInitializer.cs`
- `schema/schema.sql`
- All `ToDb`/`FromDb` enum mapping methods
- All manual `DBNull.Value` handling
- `BindTask` helper methods
---
## 4. Package Changes and DI Registration
### ClaudeDo.Data.csproj
- Remove: `Microsoft.Data.Sqlite`
- Remove: embedded resource for `schema.sql`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Design` (`PrivateAssets="all"`)
### ClaudeDo.Worker.Tests.csproj
- Remove: `Microsoft.Data.Sqlite`
- Add: `Microsoft.EntityFrameworkCore.Sqlite`
### App DI (Program.cs)
```csharp
// Replace SqliteConnectionFactory + singleton repos with:
sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
sc.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddScoped<ListRepository>();
sc.AddScoped<TaskRepository>();
sc.AddScoped<SubtaskRepository>();
sc.AddScoped<TagRepository>();
sc.AddScoped<WorktreeRepository>();
sc.AddScoped<TaskRunRepository>();
// Migrate at startup:
using var initScope = services.CreateScope();
initScope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
ViewModels are singletons that currently take repositories as constructor parameters. Since repositories become scoped, ViewModels switch to taking `IDbContextFactory<ClaudeDoDbContext>` and create a fresh context (+ repositories) per operation. Each ViewModel method that touches data does: `using var context = _factory.CreateDbContext();` then constructs or resolves the needed repository with that context. This mirrors the current connection-per-call pattern.
### Worker DI (Program.cs)
```csharp
builder.Services.AddDbContext<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
builder.Services.AddScoped<ListRepository>();
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<SubtaskRepository>();
builder.Services.AddScoped<TagRepository>();
builder.Services.AddScoped<WorktreeRepository>();
builder.Services.AddScoped<TaskRunRepository>();
// Migrate at startup after build:
using var scope = app.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>().Database.Migrate();
```
Worker has request scopes via SignalR hub invocations, so scoped registration works naturally.
---
## 5. Test Infrastructure
### DbFixture
`DbFixture` is rewritten as an EF Core fixture:
- Creates a temp SQLite file per test class.
- Builds `DbContextOptions<ClaudeDoDbContext>` with `UseSqlite`.
- Calls `context.Database.Migrate()` to apply the schema (also tests that migrations work).
- Exposes a `CreateContext()` method so each test gets a fresh context instance (avoids change-tracker bleed).
Tests construct repositories by passing in a fresh context from the fixture.
No mocking — tests keep hitting real SQLite, same philosophy as today.
---
## 6. Risk and Mitigation
| Risk | Mitigation |
|---|---|
| Big-bang rewrite touches nearly every file in ClaudeDo.Data | Existing tests are the safety net — all must pass after migration |
| Existing databases with schema from schema.sql | Compatibility shim: detect existing tables, mark initial migration as applied |
| Atomic queue claim semantics change | Kept as raw SQL via `FromSqlRaw` |
| Scoped lifetime vs. singleton ViewModels | `IDbContextFactory` provides on-demand contexts |
| EF change tracker overhead vs. raw ADO.NET | Negligible for this workload size; use `AsNoTracking()` for read-only queries |

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.418",
"rollForward": "latestFeature"
}
}

View File

@@ -1,100 +0,0 @@
-- ClaudeDo SQLite schema (single source of truth, 3NF)
-- Applied by Worker on first startup. WAL mode is set via PRAGMA after open.
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
working_dir TEXT NULL,
default_commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NULL,
status TEXT NOT NULL CHECK (status IN ('manual','queued','running','done','failed')),
scheduled_for TIMESTAMP NULL,
result TEXT NULL,
log_path TEXT NULL,
created_at TIMESTAMP NOT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
commit_type TEXT NOT NULL DEFAULT 'chore'
);
CREATE INDEX IF NOT EXISTS idx_tasks_list_id ON tasks(list_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS list_tags (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, tag_id)
);
CREATE TABLE IF NOT EXISTS task_tags (
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
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,
branch_name TEXT NOT NULL,
base_commit TEXT NOT NULL,
head_commit TEXT NULL,
diff_stat TEXT NULL,
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active','merged','discarded','kept')),
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);
CREATE TABLE IF NOT EXISTS subtasks (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
order_num INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subtasks_task_id ON subtasks(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');

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\ClaudeTask.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>

View File

@@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui; using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System; using System;
@@ -18,12 +19,25 @@ sealed class Program
var services = BuildServices(); var services = BuildServices();
App.Services = services; App.Services = services;
// Ensure DB schema exists using (var scope = services.CreateScope())
var factory = services.GetRequiredService<SqliteConnectionFactory>(); {
SchemaInitializer.Apply(factory); ClaudeDoDbContext.MigrateAndConfigure(
scope.ServiceProvider.GetRequiredService<ClaudeDoDbContext>());
}
BuildAvaloniaApp() try
.StartWithClassicDesktopLifetime(args); {
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
finally
{
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
}
} }
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()
@@ -44,14 +58,10 @@ sealed class Program
// Infrastructure // Infrastructure
sc.AddSingleton(settings); sc.AddSingleton(settings);
sc.AddSingleton(new SqliteConnectionFactory(dbPath)); sc.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={dbPath}"));
// Repositories sc.AddScoped<ClaudeDoDbContext>(sp =>
sc.AddSingleton<ListRepository>(); sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
sc.AddSingleton<TaskRepository>();
sc.AddSingleton<SubtaskRepository>();
sc.AddSingleton<TagRepository>();
sc.AddSingleton<WorktreeRepository>();
// Services // Services
sc.AddSingleton<GitService>(); sc.AddSingleton<GitService>();
@@ -61,30 +71,21 @@ sealed class Program
sc.AddTransient<ListEditorViewModel>(); sc.AddTransient<ListEditorViewModel>();
sc.AddTransient<TaskEditorViewModel>(); sc.AddTransient<TaskEditorViewModel>();
sc.AddSingleton<StatusBarViewModel>(); sc.AddSingleton<StatusBarViewModel>();
sc.AddSingleton<TaskDetailViewModel>(sp => new TaskDetailViewModel( sc.AddSingleton<TaskDetailViewModel>();
sp.GetRequiredService<TaskRepository>(),
sp.GetRequiredService<WorktreeRepository>(),
sp.GetRequiredService<ListRepository>(),
sp.GetRequiredService<GitService>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TagRepository>(),
sp.GetRequiredService<SubtaskRepository>()));
sc.AddSingleton<TaskListViewModel>(sp => sc.AddSingleton<TaskListViewModel>(sp =>
{ {
var taskRepo = sp.GetRequiredService<TaskRepository>(); var dbFactory = sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>();
var tagRepo = sp.GetRequiredService<TagRepository>();
var listRepo = sp.GetRequiredService<ListRepository>();
var worker = sp.GetRequiredService<WorkerClient>(); var worker = sp.GetRequiredService<WorkerClient>();
var statusBar = sp.GetRequiredService<StatusBarViewModel>(); var statusBar = sp.GetRequiredService<StatusBarViewModel>();
return new TaskListViewModel( return new TaskListViewModel(
taskRepo, tagRepo, listRepo, worker, dbFactory, worker,
() => sp.GetRequiredService<TaskEditorViewModel>(), () => sp.GetRequiredService<TaskEditorViewModel>(),
msg => statusBar.ShowMessage(msg)); msg => statusBar.ShowMessage(msg));
}); });
sc.AddSingleton<MainWindowViewModel>(sp => sc.AddSingleton<MainWindowViewModel>(sp =>
{ {
return new MainWindowViewModel( return new MainWindowViewModel(
sp.GetRequiredService<ListRepository>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(), sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<TaskListViewModel>(), sp.GetRequiredService<TaskListViewModel>(),
sp.GetRequiredService<TaskDetailViewModel>(), sp.GetRequiredService<TaskDetailViewModel>(),

View File

@@ -11,7 +11,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Repositories ## Repositories
All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work. All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. Exception: `TaskRepository.GetNextQueuedAgentTaskAsync` uses `FromSqlRaw` for atomic queue claim.
- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync` - **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
- **ListRepository** — CRUD, tag junction management - **ListRepository** — CRUD, tag junction management
@@ -20,8 +20,8 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Infrastructure ## Infrastructure
- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA - **ClaudeDoDbContext** — EF Core DbContext; configured with WAL mode and foreign keys via `UseSqlite` options
- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE) - **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app` - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl) - **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
@@ -31,11 +31,11 @@ All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each met
## Schema ## Schema
6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual". 6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. Managed by EF Core migrations in the `Migrations/` folder. Seed data: tags "agent" and "manual".
## Conventions ## Conventions
- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum - Enum <-> string mapping via EF Core `ValueConverter` (configured in `IEntityTypeConfiguration<T>`)
- Entity configurations live in the `Configuration/` folder
- Primary keys are `init`-only strings (GUIDs assigned at creation) - Primary keys are `init`-only strings (GUIDs assigned at creation)
- Nullable fields use `DBNull.Value` checks
- All methods are async with CancellationToken where applicable - All methods are async with CancellationToken where applicable

View File

@@ -7,11 +7,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
</ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<ItemGroup> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" /> </PackageReference>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,64 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext
{
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<TagEntity> Tags => Set<TagEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ClaudeDoDbContext).Assembly);
}
/// <summary>
/// Applies EF Core migrations and sets WAL mode. Safe for both fresh and existing databases.
/// Existing databases (created by the old schema.sql) have their tables but no
/// __EFMigrationsHistory — this method detects that case and baselines the initial
/// migration so EF skips re-creating tables that already exist.
/// </summary>
public static void MigrateAndConfigure(ClaudeDoDbContext db)
{
// If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration.
var conn = db.Database.GetDbConnection();
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='lists'";
var hasLists = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'";
var hasHistory = Convert.ToInt64(cmd.ExecuteScalar()) > 0;
if (hasLists && !hasHistory)
{
// Create the history table and mark InitialCreate as applied.
cmd.CommandText = """
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260416064948_InitialCreate', '8.0.11');
""";
cmd.ExecuteNonQuery();
}
}
conn.Close();
db.Database.Migrate();
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL");
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace ClaudeDo.Data;
public sealed class ClaudeDoDbContextFactory : IDesignTimeDbContextFactory<ClaudeDoDbContext>
{
public ClaudeDoDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("Data Source=design-time.db")
.Options;
return new ClaudeDoDbContext(options);
}
}

View File

@@ -0,0 +1,19 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfigEntity>
{
public void Configure(EntityTypeBuilder<ListConfigEntity> builder)
{
builder.ToTable("list_config");
builder.HasKey(c => c.ListId);
builder.Property(c => c.ListId).HasColumnName("list_id");
builder.Property(c => c.Model).HasColumnName("model");
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
}
}

View File

@@ -0,0 +1,36 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
{
public void Configure(EntityTypeBuilder<ListEntity> builder)
{
builder.ToTable("lists");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id).HasColumnName("id");
builder.Property(l => l.Name).HasColumnName("name").IsRequired();
builder.Property(l => l.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.HasOne(l => l.Config)
.WithOne(c => c.List)
.HasForeignKey<ListConfigEntity>(c => c.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(l => l.Tags)
.WithMany(tag => tag.Lists)
.UsingEntity("list_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(ListEntity)).WithMany().HasForeignKey("list_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("list_id", "tag_id");
j.ToTable("list_tags");
});
}
}

View File

@@ -0,0 +1,28 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class SubtaskEntityConfiguration : IEntityTypeConfiguration<SubtaskEntity>
{
public void Configure(EntityTypeBuilder<SubtaskEntity> builder)
{
builder.ToTable("subtasks");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id");
builder.Property(s => s.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.Completed).HasColumnName("completed").IsRequired().HasDefaultValue(false);
builder.Property(s => s.OrderNum).HasColumnName("order_num").IsRequired();
builder.Property(s => s.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(s => s.Task)
.WithMany(t => t.Subtasks)
.HasForeignKey(s => s.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(s => s.TaskId).HasDatabaseName("idx_subtasks_task_id");
}
}

View File

@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TagEntityConfiguration : IEntityTypeConfiguration<TagEntity>
{
public void Configure(EntityTypeBuilder<TagEntity> builder)
{
builder.ToTable("tags");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id").ValueGeneratedOnAdd();
builder.Property(t => t.Name).HasColumnName("name").IsRequired();
builder.HasIndex(t => t.Name).IsUnique();
builder.HasData(
new TagEntity { Id = 1, Name = "agent" },
new TagEntity { Id = 2, Name = "manual" });
}
}

View File

@@ -0,0 +1,75 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Configuration;
public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
{
private static string StatusToString(TaskStatus v)
=> v == TaskStatus.Manual ? "manual"
: v == TaskStatus.Queued ? "queued"
: v == TaskStatus.Running ? "running"
: v == TaskStatus.Done ? "done"
: v == TaskStatus.Failed ? "failed"
: throw new ArgumentOutOfRangeException(nameof(v));
private static TaskStatus StatusFromString(string v)
=> v == "manual" ? TaskStatus.Manual
: v == "queued" ? TaskStatus.Queued
: v == "running" ? TaskStatus.Running
: v == "done" ? TaskStatus.Done
: v == "failed" ? TaskStatus.Failed
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<TaskStatus, string> StatusConverter =
new(v => StatusToString(v), v => StatusFromString(v));
public void Configure(EntityTypeBuilder<TaskEntity> builder)
{
builder.ToTable("tasks");
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
builder.Property(t => t.Status).HasColumnName("status").IsRequired()
.HasConversion(StatusConverter);
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at");
builder.Property(t => t.FinishedAt).HasColumnName("finished_at");
builder.Property(t => t.CommitType).HasColumnName("commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(t => t.Model).HasColumnName("model");
builder.Property(t => t.SystemPrompt).HasColumnName("system_prompt");
builder.Property(t => t.AgentPath).HasColumnName("agent_path");
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(t => t.Worktree)
.WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasMany(t => t.Tags)
.WithMany(tag => tag.Tasks)
.UsingEntity("task_tags",
l => l.HasOne(typeof(TagEntity)).WithMany().HasForeignKey("tag_id").OnDelete(DeleteBehavior.Cascade),
r => r.HasOne(typeof(TaskEntity)).WithMany().HasForeignKey("task_id").OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey("task_id", "tag_id");
j.ToTable("task_tags");
});
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
}
}

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity>
{
public void Configure(EntityTypeBuilder<TaskRunEntity> builder)
{
builder.ToTable("task_runs");
builder.HasKey(r => r.Id);
builder.Property(r => r.Id).HasColumnName("id");
builder.Property(r => r.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(r => r.RunNumber).HasColumnName("run_number").IsRequired();
builder.Property(r => r.SessionId).HasColumnName("session_id");
builder.Property(r => r.IsRetry).HasColumnName("is_retry").IsRequired().HasDefaultValue(false);
builder.Property(r => r.Prompt).HasColumnName("prompt").IsRequired();
builder.Property(r => r.ResultMarkdown).HasColumnName("result_markdown");
builder.Property(r => r.StructuredOutputJson).HasColumnName("structured_output");
builder.Property(r => r.ErrorMarkdown).HasColumnName("error_markdown");
builder.Property(r => r.ExitCode).HasColumnName("exit_code");
builder.Property(r => r.TurnCount).HasColumnName("turn_count");
builder.Property(r => r.TokensIn).HasColumnName("tokens_in");
builder.Property(r => r.TokensOut).HasColumnName("tokens_out");
builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.HasOne(r => r.Task)
.WithMany(t => t.Runs)
.HasForeignKey(r => r.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(r => r.TaskId).HasDatabaseName("idx_task_runs_task_id");
}
}

View File

@@ -0,0 +1,43 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ClaudeDo.Data.Configuration;
public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEntity>
{
private static string StateToString(WorktreeState v)
=> v == WorktreeState.Active ? "active"
: v == WorktreeState.Merged ? "merged"
: v == WorktreeState.Discarded ? "discarded"
: v == WorktreeState.Kept ? "kept"
: throw new ArgumentOutOfRangeException(nameof(v));
private static WorktreeState StateFromString(string v)
=> v == "active" ? WorktreeState.Active
: v == "merged" ? WorktreeState.Merged
: v == "discarded" ? WorktreeState.Discarded
: v == "kept" ? WorktreeState.Kept
: throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<WorktreeState, string> StateConverter =
new(v => StateToString(v), v => StateFromString(v));
public void Configure(EntityTypeBuilder<WorktreeEntity> builder)
{
builder.ToTable("worktrees");
builder.HasKey(w => w.TaskId);
builder.Property(w => w.TaskId).HasColumnName("task_id");
builder.Property(w => w.Path).HasColumnName("path").IsRequired();
builder.Property(w => w.BranchName).HasColumnName("branch_name").IsRequired();
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter);
builder.Property(w => w.CreatedAt).HasColumnName("created_at").IsRequired();
}
}

View File

@@ -104,20 +104,34 @@ public sealed class GitService
using var proc = new Process { StartInfo = psi }; using var proc = new Process { StartInfo = psi };
proc.Start(); proc.Start();
// On cancellation: kill the git process tree. Killing closes the
// redirected pipes, which unblocks the ReadToEndAsync calls below
// and lets WaitForExitAsync return so the process is reaped.
// Without this, cancelling mid-git leaves zombie processes.
await using var ctr = ct.Register(() =>
{
try { proc.Kill(entireProcessTree: true); }
catch { /* already exited */ }
});
if (stdinData is not null) if (stdinData is not null)
{ {
await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct); await proc.StandardInput.WriteAsync(stdinData.AsMemory(), ct);
proc.StandardInput.Close(); proc.StandardInput.Close();
} }
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // Drain output without ct — pipes close when the process exits
var stderrTask = proc.StandardError.ReadToEndAsync(ct); // (whether naturally or via Kill above), so these always complete.
var stdoutTask = proc.StandardOutput.ReadToEndAsync();
var stderrTask = proc.StandardError.ReadToEndAsync();
await proc.WaitForExitAsync(ct); await proc.WaitForExitAsync(CancellationToken.None);
var stdout = await stdoutTask; var stdout = await stdoutTask;
var stderr = await stderrTask; var stderr = await stderrTask;
ct.ThrowIfCancellationRequested();
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd()); return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
} }
} }

View File

@@ -0,0 +1,298 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "lists",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
working_dir = table.Column<string>(type: "TEXT", nullable: true),
default_commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore")
},
constraints: table =>
{
table.PrimaryKey("PK_lists", x => x.id);
});
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_tags", x => x.id);
});
migrationBuilder.CreateTable(
name: "list_config",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_list_config", x => x.list_id);
table.ForeignKey(
name: "FK_list_config_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
list_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: true),
status = table.Column<string>(type: "TEXT", nullable: false),
scheduled_for = table.Column<DateTime>(type: "TEXT", nullable: true),
result = table.Column<string>(type: "TEXT", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true),
commit_type = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "chore"),
model = table.Column<string>(type: "TEXT", nullable: true),
system_prompt = table.Column<string>(type: "TEXT", nullable: true),
agent_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_tasks", x => x.id);
table.ForeignKey(
name: "FK_tasks_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "list_tags",
columns: table => new
{
list_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_tags", x => new { x.list_id, x.tag_id });
table.ForeignKey(
name: "FK_list_tags_lists_list_id",
column: x => x.list_id,
principalTable: "lists",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_list_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subtasks",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
title = table.Column<string>(type: "TEXT", nullable: false),
completed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
order_num = table.Column<int>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subtasks", x => x.id);
table.ForeignKey(
name: "FK_subtasks_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_runs",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
run_number = table.Column<int>(type: "INTEGER", nullable: false),
session_id = table.Column<string>(type: "TEXT", nullable: true),
is_retry = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
prompt = table.Column<string>(type: "TEXT", nullable: false),
result_markdown = table.Column<string>(type: "TEXT", nullable: true),
structured_output = table.Column<string>(type: "TEXT", nullable: true),
error_markdown = table.Column<string>(type: "TEXT", nullable: true),
exit_code = table.Column<int>(type: "INTEGER", nullable: true),
turn_count = table.Column<int>(type: "INTEGER", nullable: true),
tokens_in = table.Column<int>(type: "INTEGER", nullable: true),
tokens_out = table.Column<int>(type: "INTEGER", nullable: true),
log_path = table.Column<string>(type: "TEXT", nullable: true),
started_at = table.Column<DateTime>(type: "TEXT", nullable: true),
finished_at = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_task_runs", x => x.id);
table.ForeignKey(
name: "FK_task_runs_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "task_tags",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
tag_id = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_tags", x => new { x.task_id, x.tag_id });
table.ForeignKey(
name: "FK_task_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_tags_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "worktrees",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
path = table.Column<string>(type: "TEXT", nullable: false),
branch_name = table.Column<string>(type: "TEXT", nullable: false),
base_commit = table.Column<string>(type: "TEXT", nullable: false),
head_commit = table.Column<string>(type: "TEXT", nullable: true),
diff_stat = table.Column<string>(type: "TEXT", nullable: true),
state = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "active"),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_worktrees", x => x.task_id);
table.ForeignKey(
name: "FK_worktrees_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "tags",
columns: new[] { "id", "name" },
values: new object[,]
{
{ 1L, "agent" },
{ 2L, "manual" }
});
migrationBuilder.CreateIndex(
name: "IX_list_tags_tag_id",
table: "list_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_subtasks_task_id",
table: "subtasks",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_tags_name",
table: "tags",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "idx_task_runs_task_id",
table: "task_runs",
column: "task_id");
migrationBuilder.CreateIndex(
name: "IX_task_tags_tag_id",
table: "task_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_list_id",
table: "tasks",
column: "list_id");
migrationBuilder.CreateIndex(
name: "idx_tasks_status",
table: "tasks",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "list_config");
migrationBuilder.DropTable(
name: "list_tags");
migrationBuilder.DropTable(
name: "subtasks");
migrationBuilder.DropTable(
name: "task_runs");
migrationBuilder.DropTable(
name: "task_tags");
migrationBuilder.DropTable(
name: "worktrees");
migrationBuilder.DropTable(
name: "tags");
migrationBuilder.DropTable(
name: "tasks");
migrationBuilder.DropTable(
name: "lists");
}
}
}

View File

@@ -0,0 +1,479 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
partial class ClaudeDoDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TagEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("tags", (string)null);
b.HasData(
new
{
Id = 1L,
Name = "agent"
},
new
{
Id = 2L,
Name = "manual"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("list_tags", b =>
{
b.Property<string>("list_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("list_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("list_tags", (string)null);
});
modelBuilder.Entity("task_tags", b =>
{
b.Property<string>("task_id")
.HasColumnType("TEXT");
b.Property<long>("tag_id")
.HasColumnType("INTEGER");
b.HasKey("task_id", "tag_id");
b.HasIndex("tag_id");
b.ToTable("task_tags", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("list_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", null)
.WithMany()
.HasForeignKey("list_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("task_tags", b =>
{
b.HasOne("ClaudeDo.Data.Models.TagEntity", null)
.WithMany()
.HasForeignKey("tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("task_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -6,4 +6,7 @@ public sealed class ListConfigEntity
public string? Model { get; set; } public string? Model { get; set; }
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
} }

View File

@@ -7,4 +7,9 @@ public sealed class ListEntity
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = "chore"; public string DefaultCommitType { get; set; } = "chore";
// Navigation properties
public ListConfigEntity? Config { get; set; }
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
} }

View File

@@ -8,4 +8,7 @@ public sealed class SubtaskEntity
public bool Completed { get; set; } public bool Completed { get; set; }
public int OrderNum { get; set; } public int OrderNum { get; set; }
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
} }

View File

@@ -4,4 +4,8 @@ public sealed class TagEntity
{ {
public long Id { get; init; } public long Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
// Navigation properties
public ICollection<ListEntity> Lists { get; set; } = new List<ListEntity>();
public ICollection<TaskEntity> Tasks { get; set; } = new List<TaskEntity>();
} }

View File

@@ -26,4 +26,11 @@ public sealed class TaskEntity
public string? Model { get; set; } public string? Model { get; set; }
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
// Navigation properties
public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
} }

View File

@@ -18,4 +18,7 @@ public sealed class TaskRunEntity
public string? LogPath { get; set; } public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; } public DateTime? FinishedAt { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
} }

View File

@@ -18,4 +18,7 @@ public sealed class WorktreeEntity
public string? DiffStat { get; set; } public string? DiffStat { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active; public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
} }

View File

@@ -1,157 +1,89 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository public sealed class ListRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public ListRepository(SqliteConnectionFactory factory) => _factory = factory; public ListRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO lists (id, name, created_at, working_dir, default_commit_type)
VALUES (@id, @name, @created_at, @working_dir, @default_commit_type)
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE lists SET name = @name, working_dir = @working_dir,
default_commit_type = @default_commit_type
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string listId, CancellationToken ct = default) public async Task DeleteAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default) public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadList(reader);
} }
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<ListEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadList(reader));
return result;
} }
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default) public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists
await using var cmd = conn.CreateCommand(); .Where(l => l.Id == listId)
cmd.CommandText = """ .SelectMany(l => l.Tags)
SELECT t.id, t.name FROM tags t .ToListAsync(ct);
JOIN list_tags lt ON lt.tag_id = t.id
WHERE lt.list_id = @list_id
""";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)"; if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@list_id", listId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); list.Tags.Add(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id"; if (tag is not null)
cmd.Parameters.AddWithValue("@list_id", listId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); list.Tags.Remove(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
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); public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
if (!await reader.ReadAsync(ct)) return null; {
return new ListConfigEntity var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
if (existing is null)
{ {
ListId = reader.GetString(0), _context.ListConfigs.Add(config);
Model = reader.IsDBNull(1) ? null : reader.GetString(1), }
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2), else
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3), {
}; existing.Model = config.Model;
existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath;
}
await _context.SaveChangesAsync(ct);
} }
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),
Name = reader.GetString(1),
CreatedAt = DateTime.Parse(reader.GetString(2)),
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
DefaultCommitType = reader.GetString(4),
};
} }

View File

@@ -1,81 +1,41 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository public sealed class SubtaskRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<SubtaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadSubtask(reader));
return result;
}
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Subtasks.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """ }
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
"""; {
BindSubtask(cmd, entity); return await _context.Subtasks
await cmd.ExecuteNonQueryAsync(ct); .Where(s => s.TaskId == taskId)
.OrderBy(s => s.OrderNum)
.ToListAsync(ct);
} }
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Subtasks.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@title", entity.Title);
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string id, CancellationToken ct = default) public async Task DeleteAsync(string subtaskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
} }
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
cmd.Parameters.AddWithValue("@id", e.Id); await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
} }
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
Title = r.GetString(2),
Completed = r.GetInt64(3) != 0,
OrderNum = r.GetInt32(4),
CreatedAt = DateTime.Parse(r.GetString(5)),
};
} }

View File

@@ -1,47 +1,28 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository public sealed class TagRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TagRepository(SqliteConnectionFactory factory) => _factory = factory; public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name FROM tags ORDER BY id";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default) public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
return await GetOrCreateAsync(conn, name, ct);
}
public static async Task<long> GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default)
{
await using var sel = conn.CreateCommand();
sel.CommandText = "SELECT id FROM tags WHERE name = @name";
sel.Parameters.AddWithValue("@name", name);
var existing = await sel.ExecuteScalarAsync(ct);
if (existing is not null) if (existing is not null)
return (long)existing; return existing.Id;
await using var ins = conn.CreateCommand(); var tag = new TagEntity { Name = name };
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id"; _context.Tags.Add(tag);
ins.Parameters.AddWithValue("@name", name); await _context.SaveChangesAsync(ct);
return tag.Id;
return (long)(await ins.ExecuteScalarAsync(ct))!;
} }
} }

View File

@@ -1,171 +1,146 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository public sealed class TaskRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory; public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region Status mapping
private static string ToDb(TaskStatus s) => s switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
TaskStatus.Running => "running",
TaskStatus.Done => "done",
TaskStatus.Failed => "failed",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static TaskStatus FromDb(string s) => s switch
{
"manual" => TaskStatus.Manual,
"queued" => TaskStatus.Queued,
"running" => TaskStatus.Running,
"done" => TaskStatus.Done,
"failed" => TaskStatus.Failed,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
#endregion
#region CRUD #region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
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,
@model, @system_prompt, @agent_path)
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
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,
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
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, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
public async Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.ListId == listId)
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"; .OrderBy(t => t.CreatedAt)
cmd.Parameters.AddWithValue("@list_id", listId); .ToListAsync(ct);
}
await using var reader = await cmd.ExecuteReaderAsync(ct); // Kept for backwards-compatibility with callers using the old name.
var result = new List<TaskEntity>(); public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
while (await reader.ReadAsync(ct)) => GetByListIdAsync(listId, ct);
result.Add(ReadTask(reader));
return result; #endregion
#region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Running)
.SetProperty(t => t.StartedAt, startedAt), ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;
var now = DateTime.UtcNow;
return await _context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, now)
.SetProperty(t => t.Result, resultText), ct);
} }
#endregion #endregion
#region Tag junction #region Tags
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.name FROM tags t
JOIN task_tags tt ON tt.tag_id = t.id
WHERE tt.task_id = @task_id
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
}
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)"; if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@task_id", taskId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); task.Tags.Add(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id"; if (tag is not null)
cmd.Parameters.AddWithValue("@task_id", taskId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); task.Tags.Remove(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
} }
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var taskTags = _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.Id == taskId)
cmd.CommandText = """ .SelectMany(t => t.Tags);
SELECT DISTINCT t.id, t.name FROM tags t var listTags = _context.Tasks
WHERE t.id IN ( .Where(t => t.Id == taskId)
SELECT tag_id FROM task_tags WHERE task_id = @task_id .SelectMany(t => t.List.Tags);
UNION return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
SELECT lt.tag_id FROM list_tags lt
JOIN tasks tk ON tk.list_id = lt.list_id
WHERE tk.id = @task_id
)
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
} }
#endregion #endregion
@@ -174,136 +149,38 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); // Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
await using var cmd = conn.CreateCommand(); // Uses raw SQL because EF cannot express UPDATE...RETURNING.
cmd.CommandText = """ // Includes both task-level and list-level "agent" tag so lists tagged "agent"
SELECT t.id, t.list_id, t.title, t.description, t.status, t.scheduled_for, // automatically enqueue all their tasks without per-task tagging.
t.result, t.log_path, t.created_at, t.started_at, t.finished_at, t.commit_type, // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
t.model, t.system_prompt, t.agent_path var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
FROM tasks t var result = await _context.Tasks.FromSqlRaw("""
WHERE t.status = 'queued' UPDATE tasks SET status = 'running'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) WHERE id = (
AND EXISTS ( SELECT t.id FROM tasks t
SELECT 1 FROM task_tags tt WHERE t.status = 'queued'
JOIN tags tg ON tg.id = tt.tag_id AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
WHERE tt.task_id = t.id AND tg.name = 'agent' AND (
UNION EXISTS (
SELECT 1 FROM list_tags lt SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = lt.tag_id JOIN tags tg ON tg.id = tt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent' WHERE tt.task_id = t.id AND tg.name = 'agent'
) )
ORDER BY t.created_at ASC OR EXISTS (
LIMIT 1 SELECT 1 FROM list_tags lt
"""; JOIN tags tg ON tg.id = lt.tag_id
cmd.Parameters.AddWithValue("@now", now.ToString("o")); WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.created_at ASC
LIMIT 1
)
RETURNING *
""", nowStr).ToListAsync(ct);
await using var reader = await cmd.ExecuteReaderAsync(ct); return result.FirstOrDefault();
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
#endregion #endregion
#region Transitions
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@log_path", logPath);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET status = 'failed',
finished_at = @now,
result = '[stale] ' || @reason
WHERE status = 'running'
""";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("@reason", reason);
return await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Helpers
private static void BindTask(SqliteCommand cmd, TaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@list_id", e.ListId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
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()
{
Id = r.GetString(0),
ListId = r.GetString(1),
Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
Status = FromDb(r.GetString(4)),
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
Result = r.IsDBNull(6) ? null : r.GetString(6),
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
CreatedAt = DateTime.Parse(r.GetString(8)),
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
} }

View File

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

View File

@@ -1,102 +1,43 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class WorktreeRepository public sealed class WorktreeRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory; public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
private static string ToDb(WorktreeState s) => s switch
{
WorktreeState.Active => "active",
WorktreeState.Merged => "merged",
WorktreeState.Discarded => "discarded",
WorktreeState.Kept => "kept",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static WorktreeState FromDb(string s) => s switch
{
"active" => WorktreeState.Active,
"merged" => WorktreeState.Merged,
"discarded" => WorktreeState.Discarded,
"kept" => WorktreeState.Kept,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default) public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Worktrees.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at)
VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at)
""";
cmd.Parameters.AddWithValue("@task_id", entity.TaskId);
cmd.Parameters.AddWithValue("@path", entity.Path);
cmd.Parameters.AddWithValue("@branch_name", entity.BranchName);
cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit);
cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value);
cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value);
cmd.Parameters.AddWithValue("@state", ToDb(entity.State));
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default) public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadWorktree(reader);
} }
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default) public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s
cmd.Parameters.AddWithValue("@task_id", taskId); .SetProperty(w => w.HeadCommit, headCommit)
cmd.Parameters.AddWithValue("@head_commit", headCommit); .SetProperty(w => w.DiffStat, diffStat), ct);
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default) public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
cmd.Parameters.AddWithValue("@task_id", taskId);
cmd.Parameters.AddWithValue("@state", ToDb(state));
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id";
cmd.Parameters.AddWithValue("@task_id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
} }
private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new()
{
TaskId = r.GetString(0),
Path = r.GetString(1),
BranchName = r.GetString(2),
BaseCommit = r.GetString(3),
HeadCommit = r.IsDBNull(4) ? null : r.GetString(4),
DiffStat = r.IsDBNull(5) ? null : r.GetString(5),
State = FromDb(r.GetString(6)),
CreatedAt = DateTime.Parse(r.GetString(7)),
};
} }

View File

@@ -1,67 +0,0 @@
using System.Reflection;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
/// IF NOT EXISTS / INSERT OR IGNORE.
/// </summary>
public static class SchemaInitializer
{
private const string ResourceName = "ClaudeDo.Data.schema.sql";
public static void Apply(SqliteConnectionFactory factory)
{
using var conn = factory.Open();
ApplyTo(conn);
}
public static void ApplyTo(SqliteConnection conn)
{
var sql = LoadScript();
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
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()
{
var asm = typeof(SchemaInitializer).Assembly;
using var stream = asm.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException(
$"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " +
$"Available: {string.Join(", ", asm.GetManifestResourceNames())}");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data;
/// <summary>
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
/// First call ensures the parent directory exists, enables WAL and foreign keys.
/// </summary>
public sealed class SqliteConnectionFactory
{
public string DbPath { get; }
private readonly string _connectionString;
private int _walApplied;
public SqliteConnectionFactory(string dbPath)
{
DbPath = Paths.Expand(dbPath);
Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = DbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
}.ToString();
}
public SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// WAL is a persistent DB-level setting; applying it once per process is enough,
// but idempotent so we do it defensively on the first connection we hand out.
if (Interlocked.Exchange(ref _walApplied, 1) == 0)
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA journal_mode=WAL;";
pragma.ExecuteNonQuery();
}
using var fk = conn.CreateCommand();
fk.CommandText = "PRAGMA foreign_keys=ON;";
fk.ExecuteNonQuery();
return conn;
}
}

View File

@@ -0,0 +1,15 @@
<Application x:Class="ClaudeDo.Installer.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:core="clr-namespace:ClaudeDo.Installer.Core">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Theme/DarkTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
<core:NullToCollapsedConverter x:Key="NullToCollapsedConverter"/>
<core:StepActiveConverter x:Key="StepActiveConverter"/>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,132 @@
using System.Net.Http;
using System.Reflection;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.PathsPage;
using ClaudeDo.Installer.Pages.ServicePage;
using ClaudeDo.Installer.Pages.UiSettingsPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using ClaudeDo.Installer.Steps;
using ClaudeDo.Installer.Views;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer;
public partial class App : Application
{
private ServiceProvider? _services;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = BuildServices();
var context = _services.GetRequiredService<InstallContext>();
context.InstallerVersion = GetInstallerVersion();
// Default install dir for detection — on upgrade we stay where we were.
var detector = _services.GetRequiredService<InstallModeDetector>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Read manifest up front so we can fall back to Config if the API times out
// on an existing install. If the API is slow, we do NOT want to drop an
// already-installed user into FreshInstall — that would risk overwriting them.
var existingManifest = InstallManifestStore.TryRead(context.InstallDirectory);
DetectedState state;
try
{
state = await detector.DetectAsync(context.InstallDirectory, cts.Token);
}
catch (OperationCanceledException)
{
state = existingManifest is not null
? new DetectedState(InstallerMode.Config, existingManifest, null, null)
: new DetectedState(InstallerMode.FreshInstall, null, null, null);
}
context.Mode = state.Mode;
context.InstalledVersion = state.Existing?.Version;
context.LatestVersion = state.LatestVersion;
if (state.Existing is not null)
context.InstallDirectory = state.Existing.InstallDir;
Window mainWindow = state.Mode switch
{
InstallerMode.FreshInstall or InstallerMode.Update => new WizardWindow
{
DataContext = _services.GetRequiredService<WizardViewModel>()
},
InstallerMode.Config => new SettingsWindow
{
DataContext = _services.GetRequiredService<SettingsViewModel>()
},
_ => throw new InvalidOperationException($"Unknown installer mode: {state.Mode}")
};
DarkTitleBar.Apply(mainWindow);
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose();
base.OnExit(e);
}
private static string GetInstallerVersion()
{
var infoAttr = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
return infoAttr?.InformationalVersion ?? "0.0.0";
}
private static ServiceProvider BuildServices()
{
var sc = new ServiceCollection();
// Core
sc.AddSingleton<InstallContext>();
sc.AddSingleton<PageResolver>();
// HTTP + release client
sc.AddSingleton(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallModeDetector>();
// Pages
sc.AddSingleton<IInstallerPage, WelcomePageViewModel>();
sc.AddSingleton<IInstallerPage, PathsPageViewModel>();
sc.AddSingleton<IInstallerPage, ServicePageViewModel>();
sc.AddSingleton<IInstallerPage, UiSettingsPageViewModel>();
sc.AddSingleton<IInstallerPage, InstallPageViewModel>();
// Steps — execution order matters for the FreshInstall pipeline (IEnumerable<IInstallStep>).
// Double-registered as both IInstallStep and concrete type so Task 15's Update pipeline
// can pull them out individually via GetRequiredService<T>().
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<IInstallStep, RegisterServiceStep>();
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<IInstallStep, WriteUninstallRegistryStep>();
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
// Stop/Start — NOT registered as IInstallStep (not part of default FreshInstall pipeline).
// Pulled by Update flow + Repair/Uninstall.
sc.AddSingleton<StopServiceStep>();
sc.AddSingleton<StartServiceStep>();
// Runners
sc.AddSingleton<UninstallRunner>();
// ViewModels
sc.AddSingleton<WizardViewModel>();
sc.AddSingleton<SettingsViewModel>();
return sc.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Allow Linux Gitea runners to publish this WPF project for win-x64; no-op on Windows. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<ApplicationIcon>ClaudeTaskSetup.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- Embed icon so it is available via pack URI in WPF windows. -->
<Resource Include="ClaudeTaskSetup.ico" />
</ItemGroup>
<!-- Debug: asInvoker so Rider/VS can debug without elevation -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<ApplicationManifest>app.debug.manifest</ApplicationManifest>
</PropertyGroup>
<!-- Release: requireAdministrator for service registration + shortcuts -->
<PropertyGroup Condition="'$(Configuration)' != 'Debug'">
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition="'$(PublishSingleFile)' == 'true'">
<!-- Framework-dependent: the WPF runtime pack isn't distributed for cross-compile
on Linux CI, which made self-contained bundles crash on startup with AV in the
apphost. Target machines already have the .NET 8 Desktop Runtime. -->
<SelfContained>false</SelfContained>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
<PublishTrimmed>false</PublishTrimmed>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -0,0 +1,37 @@
using System.IO;
using System.Security.Cryptography;
namespace ClaudeDo.Installer.Core;
public static class ChecksumVerifier
{
public static string ComputeSha256(string filePath)
{
using var stream = File.OpenRead(filePath);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string filePath, string expectedSha256)
{
var actual = ComputeSha256(filePath);
return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, string> ParseChecksumsFile(string content)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in content.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var hashPart = parts[0].Trim();
if (hashPart.Length != 64) continue;
map[parts[1].Trim()] = hashPart;
}
return map;
}
}

View File

@@ -0,0 +1,109 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Mirrors ClaudeDo.Worker.Config.WorkerConfig JSON shape.
/// Keep in sync with src/ClaudeDo.Worker/Config/WorkerConfig.cs.
/// </summary>
public sealed class InstallerWorkerConfig
{
[JsonPropertyName("db_path")]
public string DbPath { get; set; } = "~/.todo-app/todo.db";
[JsonPropertyName("sandbox_root")]
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
[JsonPropertyName("log_root")]
public string LogRoot { get; set; } = "~/.todo-app/logs";
[JsonPropertyName("worktree_root_strategy")]
public string WorktreeRootStrategy { get; set; } = "sibling";
[JsonPropertyName("central_worktree_root")]
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
[JsonPropertyName("queue_backstop_interval_ms")]
public int QueueBackstopIntervalMs { get; set; } = 30_000;
[JsonPropertyName("signalr_port")]
public int SignalRPort { get; set; } = 47_821;
[JsonPropertyName("claude_bin")]
public string ClaudeBin { get; set; } = "claude";
private static readonly JsonSerializerOptions ReadOpts = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerWorkerConfig Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
if (!File.Exists(path)) return new();
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerWorkerConfig>(json, ReadOpts) ?? new();
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "worker.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}
/// <summary>
/// Mirrors ClaudeDo.Ui.AppSettings JSON shape.
/// Keep in sync with src/ClaudeDo.Ui/AppSettings.cs.
/// </summary>
public sealed class InstallerAppSettings
{
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
private static readonly JsonSerializerOptions ReadOpts = new()
{
PropertyNameCaseInsensitive = true,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
public static InstallerAppSettings Load()
{
var path = Path.Combine(Paths.AppDataRoot(), "ui.config.json");
if (!File.Exists(path)) return new();
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallerAppSettings>(json, ReadOpts) ?? new();
}
catch
{
return new();
}
}
public void Save()
{
var dir = Paths.AppDataRoot();
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "ui.config.json");
var json = JsonSerializer.Serialize(this, WriteOpts);
File.WriteAllText(path, json);
}
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace ClaudeDo.Installer.Core;
public static class DarkTitleBar
{
[DllImport("dwmapi.dll", PreserveSig = true)]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
public static void Apply(Window window)
{
if (window.IsLoaded)
SetDarkMode(window);
else
window.SourceInitialized += (_, _) => SetDarkMode(window);
}
private static void SetDarkMode(Window window)
{
var hwnd = new WindowInteropHelper(window).Handle;
if (hwnd == IntPtr.Zero) return;
int value = 1;
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
}
}

View File

@@ -0,0 +1,20 @@
namespace ClaudeDo.Installer.Core;
public interface IInstallStep
{
string Name { get; }
Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct);
}
public sealed class StepResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public static StepResult Ok() => new() { Success = true };
public static StepResult Fail(string error) => new() { Success = false, ErrorMessage = error };
}
public enum StepStatus { Pending, Running, Done, Failed, Skipped }
public sealed record StepProgress(string StepName, StepStatus Status, string? Message = null);

View File

@@ -0,0 +1,16 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Core;
public interface IInstallerPage
{
string Title { get; }
string Icon { get; }
int Order { get; }
bool ShowInWizard { get; }
bool ShowInSettings { get; }
UserControl View { get; }
Task LoadAsync();
Task ApplyAsync();
bool Validate();
}

View File

@@ -0,0 +1,15 @@
namespace ClaudeDo.Installer.Core;
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
public sealed record GiteaRelease(
string TagName,
string Name,
IReadOnlyList<ReleaseAsset> Assets);
public interface IReleaseClient
{
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
}

View File

@@ -0,0 +1,35 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallContext
{
// WelcomePage / install destination
public string InstallDirectory { get; set; } = @"C:\Program Files\ClaudeDo";
// Mode + versions (set by App startup after InstallModeDetector runs)
public InstallerMode Mode { get; set; } = InstallerMode.FreshInstall;
public string? InstallerVersion { get; set; } // from this installer's assembly
public string? InstalledVersion { get; set; } // from install.json (or set by DownloadAndExtractStep)
public string? LatestVersion { get; set; } // from Gitea API (may be null if offline)
// PathsPage
public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string LogRoot { get; set; } = "~/.todo-app/logs";
public string SandboxRoot { get; set; } = "~/.todo-app/sandbox";
public string WorktreeRootStrategy { get; set; } = "sibling";
public string CentralWorktreeRoot { get; set; } = "~/.todo-app/worktrees";
// ServicePage
public int SignalRPort { get; set; } = 47_821;
public int QueueBackstopIntervalMs { get; set; } = 30_000;
public string ClaudeBin { get; set; } = "claude";
public string ServiceAccount { get; set; } = "LocalSystem";
public bool AutoStart { get; set; } = true;
public int RestartDelayMs { get; set; } = 5000;
// UiSettingsPage
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string UiDbPath { get; set; } = "~/.todo-app/todo.db";
// InstallPage
public bool CreateDesktopShortcut { get; set; } = true;
}

View File

@@ -0,0 +1,48 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClaudeDo.Installer.Core;
public sealed record InstallManifest(
string Version,
string InstallDir,
string WorkerDir,
DateTimeOffset InstalledAt);
public static class InstallManifestStore
{
public const string FileName = "install.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string ManifestPath(string installDir) => Path.Combine(installDir, FileName);
public static InstallManifest? TryRead(string installDir)
{
var path = ManifestPath(installDir);
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<InstallManifest>(json, JsonOptions);
}
catch
{
return null;
}
}
public static void Write(string installDir, InstallManifest manifest)
{
Directory.CreateDirectory(installDir);
var json = JsonSerializer.Serialize(manifest, JsonOptions);
File.WriteAllText(ManifestPath(installDir), json);
}
}

View File

@@ -0,0 +1,48 @@
namespace ClaudeDo.Installer.Core;
public sealed record DetectedState(
InstallerMode Mode,
InstallManifest? Existing,
GiteaRelease? LatestRelease,
string? LatestVersion);
public sealed class InstallModeDetector
{
private readonly IReleaseClient _releases;
public InstallModeDetector(IReleaseClient releases)
{
_releases = releases;
}
public async Task<DetectedState> DetectAsync(string installDir, CancellationToken ct)
{
var manifest = InstallManifestStore.TryRead(installDir);
if (manifest is null)
return new DetectedState(InstallerMode.FreshInstall, null, null, null);
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return new DetectedState(InstallerMode.Config, manifest, null, null);
var latestVersion = release.TagName.TrimStart('v', 'V');
if (IsNewer(latestVersion, manifest.Version))
return new DetectedState(InstallerMode.Update, manifest, release, latestVersion);
return new DetectedState(InstallerMode.Config, manifest, release, latestVersion);
}
/// <summary>
/// Returns true only when both versions parse as System.Version (major.minor[.build[.revision]])
/// AND latest &gt; current. Semver pre-release tags like "0.2.0-beta" fail to parse and are
/// treated as "not newer" — the user drops into Config mode with no update offered.
/// This is deliberate: offering an update we can't compare is worse than silently skipping it.
/// If the project starts shipping pre-release tags, revisit this.
/// </summary>
private static bool IsNewer(string latest, string current)
{
if (!Version.TryParse(latest, out var lv)) return false;
if (!Version.TryParse(current, out var cv)) return false;
return lv > cv;
}
}

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Installer.Core;
public enum InstallerMode
{
FreshInstall, // No install.json present -> run full wizard
Update, // install.json present, newer release available
Config, // install.json present, no update (or API unreachable)
}

View File

@@ -0,0 +1,49 @@
namespace ClaudeDo.Installer.Core;
public sealed class InstallerService
{
private readonly IEnumerable<IInstallStep> _steps;
public InstallerService(IEnumerable<IInstallStep> steps) => _steps = steps;
public async Task<IReadOnlyList<(IInstallStep Step, StepResult Result)>> ExecuteAsync(
InstallContext ctx,
IProgress<StepProgress> progress,
CancellationToken ct)
{
var results = new List<(IInstallStep, StepResult)>();
foreach (var step in _steps)
{
ct.ThrowIfCancellationRequested();
progress.Report(new StepProgress(step.Name, StepStatus.Running));
var lineProgress = new Progress<string>(msg =>
progress.Report(new StepProgress(step.Name, StepStatus.Running, msg)));
try
{
var result = await step.ExecuteAsync(ctx, lineProgress, ct);
var status = result.Success ? StepStatus.Done : StepStatus.Failed;
progress.Report(new StepProgress(step.Name, status, result.ErrorMessage));
results.Add((step, result));
if (!result.Success) break;
}
catch (OperationCanceledException)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, "Cancelled"));
results.Add((step, StepResult.Fail("Cancelled")));
break;
}
catch (Exception ex)
{
progress.Report(new StepProgress(step.Name, StepStatus.Failed, ex.Message));
results.Add((step, StepResult.Fail(ex.Message)));
break;
}
}
return results;
}
}

View File

@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace ClaudeDo.Installer.Core;
public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is null or "" ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,17 @@
namespace ClaudeDo.Installer.Core;
public sealed class PageResolver
{
private readonly IReadOnlyList<IInstallerPage> _allPages;
public PageResolver(IEnumerable<IInstallerPage> pages)
{
_allPages = pages.OrderBy(p => p.Order).ToList();
}
public IReadOnlyList<IInstallerPage> WizardPages =>
_allPages.Where(p => p.ShowInWizard).ToList();
public IReadOnlyList<IInstallerPage> SettingsPages =>
_allPages.Where(p => p.ShowInSettings).ToList();
}

View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace ClaudeDo.Installer.Core;
public static class ProcessRunner
{
public static async Task<(int ExitCode, string Output)> RunAsync(
string fileName,
string arguments,
string? workingDirectory,
IProgress<string>? progress,
CancellationToken ct)
{
var output = new StringBuilder();
var outputLock = new object();
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
if (!process.Start())
return (-1, "Failed to start process");
var stdoutTask = ReadStreamAsync(process.StandardOutput, output, outputLock, progress);
var stderrTask = ReadStreamAsync(process.StandardError, output, outputLock, progress);
using var reg = ct.Register(() =>
{
try { process.Kill(entireProcessTree: true); } catch { }
});
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(ct);
return (process.ExitCode, output.ToString());
}
private static async Task ReadStreamAsync(
StreamReader reader,
StringBuilder output,
object outputLock,
IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
lock (outputLock) { output.AppendLine(line); }
progress?.Report(line);
}
}
}

View File

@@ -0,0 +1,85 @@
using System.IO;
using System.Net.Http;
using System.Text.Json;
namespace ClaudeDo.Installer.Core;
public sealed class ReleaseClient : IReleaseClient
{
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
private readonly HttpClient _http;
private readonly string _apiBase;
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
{
_http = http;
_apiBase = apiBase.TrimEnd('/');
}
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
{
try
{
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(ct);
return ParseRelease(json);
}
catch (HttpRequestException) { return null; }
catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; }
}
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
{
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = File.Create(destPath);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
progress.Report(total);
}
}
private static GiteaRelease? ParseRelease(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
var tag = tagEl.GetString() ?? "";
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
var assets = new List<ReleaseAsset>();
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var item in arr.EnumerateArray())
{
if (!item.TryGetProperty("name", out var nameField)) continue;
if (!item.TryGetProperty("browser_download_url", out var urlField)) continue;
var aName = nameField.GetString() ?? "";
var aUrl = urlField.GetString() ?? "";
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
}
}
return new GiteaRelease(tag, name, assets);
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace ClaudeDo.Installer.Core;
/// <summary>
/// Multi-value converter: compares the page's index with the current page index
/// to determine step indicator styling.
/// </summary>
public sealed class StepActiveConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
if (values.Length < 2 ||
values[0] is not IInstallerPage page ||
values[1] is not IInstallerPage currentPage)
return DependencyProperty.UnsetValue;
var isActive = ReferenceEquals(page, currentPage);
var key = parameter?.ToString() switch
{
"Background" => isActive ? "AccentBrush" : "WindowBgBrush",
"Foreground" => isActive ? "TextPrimaryBrush" : "TextMutedBrush",
"BorderBrush" => isActive ? "AccentBrush" : "BorderSubtleBrush",
_ => null
};
if (key is null) return DependencyProperty.UnsetValue;
return Application.Current.Resources[key] as SolidColorBrush ?? DependencyProperty.UnsetValue;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,186 @@
using System.Diagnostics;
using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Installer.Steps;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Core;
public sealed class UninstallRunner
{
private readonly InstallContext _context;
private readonly StopServiceStep _stopService;
public UninstallRunner(InstallContext context, StopServiceStep stopService)
{
_context = context;
_stopService = stopService;
}
public async Task<StepResult> RunAsync(IProgress<string> progress, CancellationToken ct)
{
// 1) Validate install dir up front — refuse obviously unsafe paths.
// Prevents Directory.Delete(recursive:true) from wiping C:\ or C:\Program Files\.
if (!IsSafeInstallDir(_context.InstallDirectory, out var safeError))
return StepResult.Fail($"Refusing to uninstall: {safeError}");
// 2) Stop service. If stop fails we MUST abort — deleting a service whose
// process is still running leaves orphan locked binaries under the install dir
// which Directory.Delete will silently skip.
progress.Report("Stopping worker service...");
var stopResult = await _stopService.ExecuteAsync(_context, progress, ct);
if (!stopResult.Success)
return StepResult.Fail(
$"Cannot uninstall: worker service did not stop cleanly. {stopResult.ErrorMessage} " +
"Kill the worker manually and re-run uninstall.");
// 3) Unregister service.
progress.Report("Unregistering service...");
await ProcessRunner.RunAsync("sc.exe", $"delete {StopServiceStep.ServiceName}", null, progress, ct);
// 3b) Remove Apps & Features registry entry (best-effort).
progress.Report("Removing Add/Remove Programs entry...");
try
{
Registry.LocalMachine.DeleteSubKeyTree(WriteUninstallRegistryStep.UninstallKeyPath, throwOnMissingSubKey: false);
}
catch (Exception ex)
{
progress.Report($"Warning: could not delete uninstall registry key: {ex.Message}");
}
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"));
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk"));
// 5) Delete install directory. Track success so we can report partial state.
var failures = new List<string>();
if (Directory.Exists(_context.InstallDirectory))
{
progress.Report($"Deleting {_context.InstallDirectory}...");
if (!TryDeleteDir(_context.InstallDirectory, out var err))
failures.Add($"install dir ({_context.InstallDirectory}): {err}");
}
// 6) Delete ~/.todo-app (config + DB + logs) — user opted into full removal.
var appData = Paths.AppDataRoot();
if (Directory.Exists(appData))
{
progress.Report($"Deleting {appData}...");
if (!TryDeleteDir(appData, out var err))
failures.Add($"app data ({appData}): {err}");
}
// 7) If we were launched from inside the install dir (Apps & Features case),
// our own exe is still locked — schedule a cmd.exe trampoline to finish
// the deletion after this process exits. Best-effort: if this fails the
// user is left with an empty <uninstaller> folder which is harmless.
var runningExe = Environment.ProcessPath;
if (runningExe is not null
&& IsInsideDirectory(runningExe, _context.InstallDirectory)
&& Directory.Exists(_context.InstallDirectory))
{
progress.Report("Scheduling final cleanup after exit...");
TryScheduleTrampolineDelete(_context.InstallDirectory);
// The trampoline will finish the job — clear the residual failure entry for the install dir.
failures.RemoveAll(f => f.StartsWith("install dir"));
}
if (failures.Count > 0)
{
return StepResult.Fail(
"Uninstall partially succeeded — the following could not be removed:\n " +
string.Join("\n ", failures));
}
progress.Report("Uninstall complete.");
return StepResult.Ok();
}
private static bool IsInsideDirectory(string filePath, string directory)
{
try
{
var full = Path.GetFullPath(filePath);
var dir = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(dir, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
private static void TryScheduleTrampolineDelete(string installDir)
{
try
{
var pid = Environment.ProcessId;
// Wait for this process to exit, then recursively remove the install dir.
// /B timeout avoids a visible window; ping as a portable sleep; rmdir /S /Q is silent.
var cmd = $"/C start \"\" /MIN cmd /C \"ping 127.0.0.1 -n 3 >nul & rmdir /S /Q \"\"{installDir}\"\"\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
});
}
catch { /* best effort */ }
}
/// <summary>
/// Guards against catastrophic recursive-delete paths. The install dir must be
/// a non-root directory with a non-empty name (e.g. reject "", "C:\\", "/").
/// </summary>
private static bool IsSafeInstallDir(string path, out string reason)
{
if (string.IsNullOrWhiteSpace(path))
{
reason = "install directory is empty";
return false;
}
string full;
try { full = Path.GetFullPath(path); }
catch (Exception ex)
{
reason = $"install directory is not a valid path: {ex.Message}";
return false;
}
var name = Path.GetFileName(full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.IsNullOrEmpty(name))
{
reason = $"install directory resolves to a drive root ({full})";
return false;
}
reason = "";
return true;
}
private static void TryDeleteFile(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort — single shortcut */ }
}
private static bool TryDeleteDir(string path, out string error)
{
try
{
Directory.Delete(path, recursive: true);
error = "";
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -0,0 +1,101 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.InstallPage.InstallPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.InstallPage"
d:DataContext="{d:DesignInstance local:InstallPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="Installation" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Click Install to build and deploy ClaudeDo."
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<!-- Step List -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:StepViewModel}">
<Border Margin="0,0,0,6" Padding="10,8"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1" CornerRadius="4">
<StackPanel>
<!-- Step header -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Status indicator -->
<Ellipse Grid.Column="0" Width="10" Height="10" Margin="0,0,10,0"
VerticalAlignment="Center">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{StaticResource StatusGrayBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="Running">
<Setter Property="Fill" Value="{StaticResource StatusOrangeBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Done">
<Setter Property="Fill" Value="{StaticResource StatusGreenBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="Failed">
<Setter Property="Fill" Value="{StaticResource StatusRedBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="13"
VerticalAlignment="Center"/>
</Grid>
<!-- Messages (expandable) -->
<ItemsControl ItemsSource="{Binding Messages}" Margin="20,4,0,0"
Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisConverter}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="11" FontFamily="Consolas"
Foreground="{StaticResource TextDimBrush}"
TextWrapping="Wrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Progress Bar -->
<ProgressBar Grid.Row="2" Value="{Binding OverallProgress}" Maximum="100"
Margin="0,12,0,0"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"/>
<!-- Action buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Cancel" Command="{Binding CancelInstallCommand}"
Visibility="{Binding IsInstalling, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,8,0"/>
<Button Content="Launch ClaudeDo" Command="{Binding LaunchAppCommand}"
Style="{StaticResource AccentButton}"
Visibility="{Binding IsComplete, Converter={StaticResource BoolToVisConverter}}"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageView : UserControl
{
public InstallPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,163 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class InstallPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private readonly IServiceProvider _serviceProvider;
private InstallPageView? _view;
private CancellationTokenSource? _cts;
public string Title => "Install";
public string Icon => "\uE896";
public int Order => 99;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new InstallPageView { DataContext = this };
public ObservableCollection<StepViewModel> Steps { get; } = [];
[ObservableProperty] private bool _isInstalling;
[ObservableProperty] private bool _isComplete;
[ObservableProperty] private bool _hasErrors;
[ObservableProperty] private double _overallProgress;
public InstallPageViewModel(InstallContext context, IServiceProvider serviceProvider)
{
_context = context;
_serviceProvider = serviceProvider;
}
public Task LoadAsync()
{
Steps.Clear();
if (_context.Mode == InstallerMode.Update)
{
Steps.Add(new StepViewModel("Stop Worker Service"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Start Worker Service"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
else
{
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Windows Service"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
}
return Task.CompletedTask;
}
public Task ApplyAsync() => RunInstallAsync();
public bool Validate() => true;
[RelayCommand]
private async Task RunInstallAsync()
{
if (IsInstalling) return;
IsInstalling = true;
IsComplete = false;
HasErrors = false;
OverallProgress = 0;
_cts = new CancellationTokenSource();
var progress = new Progress<StepProgress>(p =>
{
var step = Steps.FirstOrDefault(s => s.Name == p.StepName);
if (step is null) return;
step.Status = p.Status;
if (p.Message is not null)
{
// Messages starting with "\r" overwrite the previous line (live progress).
if (p.Message.StartsWith('\r'))
{
var line = p.Message[1..];
if (step.Messages.Count > 0 && step.Messages[^1].StartsWith(" "))
step.Messages[^1] = line;
else
step.Messages.Add(line);
}
else
{
step.Messages.Add(p.Message);
}
}
if (p.Status is StepStatus.Running && !step.IsExpanded)
step.IsExpanded = true;
if (p.Status is StepStatus.Done or StepStatus.Failed)
{
var completed = Steps.Count(s => s.Status is StepStatus.Done or StepStatus.Failed);
OverallProgress = (double)completed / Steps.Count * 100;
}
});
try
{
IEnumerable<IInstallStep> steps;
if (_context.Mode == InstallerMode.Update)
{
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopServiceStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<StartServiceStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
};
}
else
{
steps = _serviceProvider.GetServices<IInstallStep>();
}
var runner = new InstallerService(steps);
var results = await runner.ExecuteAsync(_context, progress, _cts.Token);
HasErrors = results.Any(r => !r.Result.Success);
}
catch (OperationCanceledException)
{
HasErrors = true;
}
finally
{
IsInstalling = false;
IsComplete = true;
_cts.Dispose();
_cts = null;
}
}
[RelayCommand]
private void CancelInstall()
{
_cts?.Cancel();
}
[RelayCommand]
private void LaunchApp()
{
var appExe = System.IO.Path.Combine(_context.InstallDirectory, "app", "ClaudeDo.App.exe");
if (System.IO.File.Exists(appExe))
{
Process.Start(new ProcessStartInfo(appExe) { UseShellExecute = true });
Application.Current.Shutdown();
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.InstallPage;
public partial class StepViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty] private StepStatus _status = StepStatus.Pending;
[ObservableProperty] private bool _isExpanded;
public ObservableCollection<string> Messages { get; } = [];
public StepViewModel(string name) => Name = name;
}

View File

@@ -0,0 +1,41 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.PathsPage.PathsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.PathsPage"
d:DataContext="{d:DesignInstance local:PathsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Data Paths" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure where ClaudeDo stores its data."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding DbPath, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Log Directory"/>
<TextBox Text="{Binding LogRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Sandbox Root"/>
<TextBox Text="{Binding SandboxRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Worktree Strategy"/>
<ComboBox SelectedItem="{Binding WorktreeRootStrategy}" Margin="0,0,0,12">
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">sibling</sys:String>
<sys:String xmlns:sys="clr-namespace:System;assembly=mscorlib">central</sys:String>
</ComboBox>
<StackPanel Visibility="{Binding IsCentralVisible, Converter={StaticResource BoolToVisConverter}}">
<Label Content="Central Worktree Root"/>
<TextBox Text="{Binding CentralWorktreeRoot, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
</StackPanel>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageView : UserControl
{
public PathsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,74 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.PathsPage;
public partial class PathsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private PathsPageView? _view;
public string Title => "Paths";
public string Icon => "\uE8B7";
public int Order => 1;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new PathsPageView { DataContext = this };
[ObservableProperty] private string _dbPath = "~/.todo-app/todo.db";
[ObservableProperty] private string _logRoot = "~/.todo-app/logs";
[ObservableProperty] private string _sandboxRoot = "~/.todo-app/sandbox";
[ObservableProperty] private string _worktreeRootStrategy = "sibling";
[ObservableProperty] private string _centralWorktreeRoot = "~/.todo-app/worktrees";
[ObservableProperty] private string? _validationError;
public bool IsCentralVisible => WorktreeRootStrategy == "central";
public PathsPageViewModel(InstallContext context) => _context = context;
partial void OnWorktreeRootStrategyChanged(string value) =>
OnPropertyChanged(nameof(IsCentralVisible));
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
DbPath = cfg.DbPath;
LogRoot = cfg.LogRoot;
SandboxRoot = cfg.SandboxRoot;
WorktreeRootStrategy = cfg.WorktreeRootStrategy;
CentralWorktreeRoot = cfg.CentralWorktreeRoot;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.DbPath = DbPath;
_context.UiDbPath = DbPath;
_context.LogRoot = LogRoot;
_context.SandboxRoot = SandboxRoot;
_context.WorktreeRootStrategy = WorktreeRootStrategy;
_context.CentralWorktreeRoot = CentralWorktreeRoot;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(DbPath) ||
string.IsNullOrWhiteSpace(LogRoot) ||
string.IsNullOrWhiteSpace(SandboxRoot))
{
ValidationError = "All path fields are required.";
return false;
}
if (WorktreeRootStrategy == "central" && string.IsNullOrWhiteSpace(CentralWorktreeRoot))
{
ValidationError = "Central worktree root is required when using central strategy.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,56 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.ServicePage.ServicePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.ServicePage"
d:DataContext="{d:DesignInstance local:ServicePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="Worker Service" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo Worker background service."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<Label Content="SignalR Port"/>
<TextBox Text="{Binding SignalRPort, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Queue Backstop Interval (ms)"/>
<TextBox Text="{Binding QueueBackstopIntervalMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<Label Content="Claude CLI Path"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" Text="{Binding ClaudeBin, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1" Content="Browse..." Command="{Binding BrowseClaudeCommand}"
Margin="8,0,0,0"/>
</Grid>
<Separator Margin="0,4,0,12"/>
<Label Content="Service Account"/>
<StackPanel Margin="0,0,0,12">
<RadioButton Content="Local System (recommended)"
IsChecked="{Binding IsLocalSystem}" Margin="0,0,0,4"/>
<RadioButton Content="Current User"
IsChecked="{Binding IsCurrentUser}"/>
<TextBlock Text="Running as current user requires 'Log on as a service' privilege."
Foreground="{StaticResource TextDimBrush}" FontSize="11" Margin="20,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<CheckBox Content="Start service automatically" IsChecked="{Binding AutoStart}" Margin="0,0,0,12"/>
<Label Content="Restart Delay (ms)"/>
<TextBox Text="{Binding RestartDelayMs, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageView : UserControl
{
public ServicePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,88 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.ServicePage;
public partial class ServicePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private ServicePageView? _view;
public string Title => "Service";
public string Icon => "\uE912";
public int Order => 2;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new ServicePageView { DataContext = this };
[ObservableProperty] private int _signalRPort = 47_821;
[ObservableProperty] private int _queueBackstopIntervalMs = 30_000;
[ObservableProperty] private string _claudeBin = "claude";
[ObservableProperty] private bool _isLocalSystem = true;
[ObservableProperty] private bool _isCurrentUser;
[ObservableProperty] private bool _autoStart = true;
[ObservableProperty] private int _restartDelayMs = 5000;
[ObservableProperty] private string? _validationError;
public ServicePageViewModel(InstallContext context) => _context = context;
public Task LoadAsync()
{
var cfg = InstallerWorkerConfig.Load();
SignalRPort = cfg.SignalRPort;
QueueBackstopIntervalMs = cfg.QueueBackstopIntervalMs;
ClaudeBin = cfg.ClaudeBin;
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.SignalRPort = SignalRPort;
_context.QueueBackstopIntervalMs = QueueBackstopIntervalMs;
_context.ClaudeBin = ClaudeBin;
_context.ServiceAccount = IsCurrentUser ? "CurrentUser" : "LocalSystem";
_context.AutoStart = AutoStart;
_context.RestartDelayMs = RestartDelayMs;
_context.SignalRUrl = $"http://127.0.0.1:{SignalRPort}/hub";
return Task.CompletedTask;
}
public bool Validate()
{
if (SignalRPort < 1024 || SignalRPort > 65535)
{
ValidationError = "Port must be between 1024 and 65535.";
return false;
}
if (QueueBackstopIntervalMs <= 0)
{
ValidationError = "Queue backstop interval must be greater than 0.";
return false;
}
if (string.IsNullOrWhiteSpace(ClaudeBin))
{
ValidationError = "Claude CLI path is required.";
return false;
}
ValidationError = null;
return true;
}
[RelayCommand]
private void BrowseClaude()
{
var dialog = new OpenFileDialog
{
Title = "Select Claude CLI executable",
Filter = "Executables (*.exe)|*.exe|All files (*.*)|*.*",
};
if (dialog.ShowDialog() == true)
ClaudeBin = dialog.FileName;
}
}

View File

@@ -0,0 +1,36 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.UiSettingsPage.UiSettingsPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.UiSettingsPage"
d:DataContext="{d:DesignInstance local:UiSettingsPageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="UI Settings" FontSize="18" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock Text="Configure the ClaudeDo desktop UI connection settings."
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,20"
TextWrapping="Wrap"/>
<CheckBox Content="Sync with service settings" IsChecked="{Binding IsSynced}" Margin="0,0,0,16"/>
<Label Content="SignalR URL"/>
<TextBox Text="{Binding SignalRUrl, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<Label Content="Database Path"/>
<TextBox Text="{Binding UiDbPath, UpdateSourceTrigger=PropertyChanged}"
IsReadOnly="{Binding IsSynced}" Margin="0,0,0,12"/>
<TextBlock Text="When synced, these values are derived from the Service and Paths pages."
Foreground="{StaticResource TextDimBrush}" FontSize="11" TextWrapping="Wrap"
Visibility="{Binding IsSynced, Converter={StaticResource BoolToVisConverter}}"
Margin="0,0,0,12"/>
<TextBlock Text="{Binding ValidationError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageView : UserControl
{
public UiSettingsPageView() => InitializeComponent();
}

View File

@@ -0,0 +1,83 @@
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
public partial class UiSettingsPageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private UiSettingsPageView? _view;
public string Title => "UI Settings";
public string Icon => "\uE771";
public int Order => 3;
public bool ShowInWizard => true;
public bool ShowInSettings => true;
public UserControl View => _view ??= new UiSettingsPageView { DataContext = this };
[ObservableProperty] private string _signalRUrl = "http://127.0.0.1:47821/hub";
[ObservableProperty] private string _uiDbPath = "~/.todo-app/todo.db";
[ObservableProperty] private bool _isSynced = true;
[ObservableProperty] private string? _validationError;
public UiSettingsPageViewModel(InstallContext context) => _context = context;
partial void OnIsSyncedChanged(bool value)
{
if (value) SyncFromContext();
}
private void SyncFromContext()
{
SignalRUrl = $"http://127.0.0.1:{_context.SignalRPort}/hub";
UiDbPath = _context.DbPath;
}
public Task LoadAsync()
{
if (IsSynced)
{
SyncFromContext();
}
else
{
var cfg = InstallerAppSettings.Load();
SignalRUrl = cfg.SignalRUrl;
UiDbPath = cfg.DbPath;
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
if (IsSynced) SyncFromContext();
_context.SignalRUrl = SignalRUrl;
_context.UiDbPath = UiDbPath;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(SignalRUrl))
{
ValidationError = "SignalR URL is required.";
return false;
}
if (string.IsNullOrWhiteSpace(UiDbPath))
{
ValidationError = "Database path is required.";
return false;
}
if (!Uri.TryCreate(SignalRUrl, UriKind.Absolute, out _))
{
ValidationError = "SignalR URL must be a valid URL.";
return false;
}
ValidationError = null;
return true;
}
}

View File

@@ -0,0 +1,37 @@
<UserControl x:Class="ClaudeDo.Installer.Pages.WelcomePage.WelcomePageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ClaudeDo.Installer.Pages.WelcomePage"
d:DataContext="{d:DesignInstance local:WelcomePageViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel MaxWidth="520">
<TextBlock Text="{Binding Heading}" FontSize="20" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="{Binding Subheading}" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,0,0,24"/>
<Label Content="Install Directory"/>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding InstallDirectory, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding InstallDirEditable}"/>
<Button Grid.Column="1"
Content="Browse..."
Margin="8,0,0,0"
Command="{Binding BrowseInstallCommand}"
IsEnabled="{Binding InstallDirEditable}"/>
</Grid>
<TextBlock Text="{Binding InstallError}" Foreground="{StaticResource ErrorBrush}" FontSize="11"
Visibility="{Binding InstallError, Converter={StaticResource NullToCollapsedConverter}}"/>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageView : UserControl
{
public WelcomePageView() => InitializeComponent();
}

View File

@@ -0,0 +1,87 @@
using System.IO;
using System.Windows.Controls;
using ClaudeDo.Installer.Core;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Pages.WelcomePage;
public partial class WelcomePageViewModel : ObservableObject, IInstallerPage
{
private readonly InstallContext _context;
private WelcomePageView? _view;
public string Title => "Welcome";
public string Icon => "\uE80F";
public int Order => 0;
public bool ShowInWizard => true;
public bool ShowInSettings => false;
public UserControl View => _view ??= new WelcomePageView { DataContext = this };
[ObservableProperty] private string _installDirectory = @"C:\Program Files\ClaudeDo";
[ObservableProperty] private string? _installError;
[ObservableProperty] private string _heading = "Install ClaudeDo";
[ObservableProperty] private string _subheading = "Set the installation directory and continue.";
[ObservableProperty] private bool _installDirEditable = true;
public WelcomePageViewModel(InstallContext context)
{
_context = context;
}
public Task LoadAsync()
{
InstallDirectory = string.IsNullOrEmpty(_context.InstallDirectory)
? @"C:\Program Files\ClaudeDo"
: _context.InstallDirectory;
switch (_context.Mode)
{
case InstallerMode.FreshInstall:
Heading = "Install ClaudeDo";
Subheading = "Choose where to install ClaudeDo, then click Next.";
InstallDirEditable = true;
break;
case InstallerMode.Update:
Heading = $"Update ClaudeDo {_context.InstalledVersion ?? "?"} -> {_context.LatestVersion ?? "?"}";
Subheading = "Your tasks, config, and database will be preserved. Click Next to continue.";
InstallDirEditable = false; // stay where we were installed
break;
default:
// Config and any future modes should never reach the wizard; guard loudly if they do.
throw new InvalidOperationException(
$"WelcomePage is not valid for installer mode {_context.Mode}");
}
return Task.CompletedTask;
}
public Task ApplyAsync()
{
_context.InstallDirectory = InstallDirectory;
return Task.CompletedTask;
}
public bool Validate()
{
if (string.IsNullOrWhiteSpace(InstallDirectory))
{
InstallError = "Install directory is required";
return false;
}
InstallError = null;
return true;
}
[RelayCommand]
private void BrowseInstall()
{
if (!InstallDirEditable) return;
var dialog = new OpenFolderDialog { Title = "Select installation directory" };
if (dialog.ShowDialog() == true)
InstallDirectory = dialog.FolderName;
}
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class CreateShortcutsStep : IInstallStep
{
public string Name => "Create Shortcuts";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var appExe = Path.Combine(ctx.InstallDirectory, "app", "ClaudeDo.App.exe");
var workingDir = Path.Combine(ctx.InstallDirectory, "app");
// Start Menu shortcut
var startMenuDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs");
Directory.CreateDirectory(startMenuDir);
var startMenuPath = Path.Combine(startMenuDir, "ClaudeDo.lnk");
CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Start Menu shortcut: {startMenuPath}");
// Desktop shortcut (optional)
if (ctx.CreateDesktopShortcut)
{
var desktopPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk");
CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
progress.Report($"Created Desktop shortcut: {desktopPath}");
}
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
private static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
#region COM Interop for IShellLink
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { }
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
#endregion
}

View File

@@ -0,0 +1,100 @@
using System.IO;
using System.IO.Compression;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class DownloadAndExtractStep : IInstallStep
{
private readonly IReleaseClient _releases;
public DownloadAndExtractStep(IReleaseClient releases)
{
_releases = releases;
}
public string Name => "Download and Extract";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstallDirectory))
return StepResult.Fail("Install directory is not set.");
progress.Report("Fetching latest release metadata...");
var release = await _releases.GetLatestReleaseAsync(ct);
if (release is null)
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
var zipAsset = release.Assets.FirstOrDefault(a =>
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
a.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase));
var checksumAsset = release.Assets.FirstOrDefault(a =>
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
if (zipAsset is null)
return StepResult.Fail("Release zip asset not found in release metadata.");
if (checksumAsset is null)
return StepResult.Fail("checksums.txt not found in release metadata.");
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(scratchDir);
try
{
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
var totalMb = zipAsset.Size / (1024 * 1024);
progress.Report($"Downloading {zipAsset.Name} ({totalMb} MB)...");
long lastReportedMb = -1;
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
new Progress<long>(b =>
{
var mb = b / (1024 * 1024);
if (mb == lastReportedMb) return;
lastReportedMb = mb;
// Leading "\r" tells the UI to overwrite the previous line instead of appending.
progress.Report($"\r {mb} / {totalMb} MB downloaded");
}),
ct);
progress.Report("Downloading checksums...");
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
new Progress<long>(_ => { }), ct);
progress.Report("Verifying checksum...");
var map = ChecksumVerifier.ParseChecksumsFile(await File.ReadAllTextAsync(checksumPath, ct));
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
// Only after verification do we touch the install directory.
progress.Report("Clearing previous app/worker binaries...");
var appDest = Path.Combine(ctx.InstallDirectory, "app");
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory);
try
{
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
}
catch (Exception ex)
{
return StepResult.Fail(
$"Extraction failed after old binaries were removed: {ex.Message}. " +
"Your install directory may be incomplete. Re-run the installer to retry.");
}
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok();
}
finally
{
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
}
}
}

View File

@@ -0,0 +1,32 @@
using ClaudeDo.Data;
using ClaudeDo.Installer.Core;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Installer.Steps;
public sealed class InitDatabaseStep : IInstallStep
{
public string Name => "Initialize Database";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var expandedPath = Paths.Expand(ctx.DbPath);
progress.Report($"Initializing database at {expandedPath}");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={expandedPath}")
.Options;
using var context = new ClaudeDoDbContext(options);
ClaudeDoDbContext.MigrateAndConfigure(context);
progress.Report("Schema applied successfully");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,90 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterServiceStep : IInstallStep
{
private const string ServiceName = "ClaudeDoWorker";
public string Name => "Register Windows Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// Stop existing service (ignore errors — may not exist)
progress.Report("Stopping existing service (if any)...");
await RunSc($"stop {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Delete existing service (ignore errors)
progress.Report("Removing existing service registration (if any)...");
await RunSc($"delete {ServiceName}", ctx, progress, ct, ignoreErrors: true);
// Wait for the service to actually disappear from SCM. `sc delete` returns
// immediately but the service stays "marked for deletion" until every open
// handle (services.msc, Task Manager, a prior sc query process) is closed.
// Poll up to 30s — then fail with actionable guidance if it's still there.
progress.Report("Waiting for prior service registration to clear...");
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (queryExit, _) = await RunSc($"query {ServiceName}", ctx, progress, ct, ignoreErrors: true);
if (queryExit != 0) break; // service no longer registered — good
if (i == 29)
return StepResult.Fail(
$"Service '{ServiceName}' is marked for deletion but hasn't cleared after 30s. " +
"Close any open Services console (services.msc), Task Manager Services tab, or " +
"Event Viewer showing the service, then retry. A reboot will also clear it.");
await Task.Delay(1000, ct);
}
// Create service
var startType = ctx.AutoStart ? "auto" : "demand";
var createArgs = $"create {ServiceName} binPath= \"{workerExe}\" start= {startType}";
if (ctx.ServiceAccount == "CurrentUser")
{
var username = Environment.UserName;
createArgs += $" obj= \".\\{username}\"";
}
progress.Report("Creating service...");
var (exitCode, output) = await RunSc(createArgs, ctx, progress, ct);
if (exitCode == 1072)
return StepResult.Fail(
$"Service '{ServiceName}' is still marked for deletion. " +
"Close services.msc / Task Manager / Event Viewer and retry, or reboot.");
if (exitCode != 0)
return StepResult.Fail($"sc.exe create failed (exit {exitCode}): {output}");
// Configure restart policy
var delay = ctx.RestartDelayMs;
var failureArgs = $"failure {ServiceName} reset= 86400 actions= restart/{delay}/restart/{delay}/restart/{delay}";
progress.Report("Configuring restart policy...");
var (failExit, failOutput) = await RunSc(failureArgs, ctx, progress, ct);
if (failExit != 0)
progress.Report($"Warning: failed to set restart policy (exit {failExit})");
// Start service if auto-start
if (ctx.AutoStart)
{
progress.Report("Starting service...");
var (startExit, _) = await RunSc($"start {ServiceName}", ctx, progress, ct);
if (startExit != 0)
progress.Report("Warning: service created but failed to start. You may need to start it manually.");
}
return StepResult.Ok();
}
private static async Task<(int ExitCode, string Output)> RunSc(
string arguments, InstallContext ctx, IProgress<string> progress,
CancellationToken ct, bool ignoreErrors = false)
{
var result = await ProcessRunner.RunAsync("sc.exe", arguments, null, progress, ct);
return result;
}
}

View File

@@ -0,0 +1,27 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartServiceStep : IInstallStep
{
private const string ServiceName = StopServiceStep.ServiceName;
public string Name => "Start Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Starting {ServiceName}...");
var (exit, _) = await ProcessRunner.RunAsync("sc.exe", $"start {ServiceName}", null, progress, ct);
if (exit == 0) return StepResult.Ok();
// Exit 1056 = ERROR_SERVICE_ALREADY_RUNNING — that's fine too.
if (exit == 1056)
{
progress.Report("Service was already running.");
return StepResult.Ok();
}
return StepResult.Fail($"sc.exe start failed with exit code {exit}");
}
}

View File

@@ -0,0 +1,54 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopServiceStep : IInstallStep
{
public const string ServiceName = "ClaudeDoWorker";
public string Name => "Stop Worker Service";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report($"Stopping {ServiceName} (if running)...");
// sc.exe query -> returns non-zero if the service does not exist; that's fine.
var (queryExit, queryOutput) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (queryExit != 0)
{
progress.Report("Service is not registered — nothing to stop.");
return StepResult.Ok();
}
if (queryOutput.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service is already stopped.");
return StepResult.Ok();
}
var (stopExit, _) = await ProcessRunner.RunAsync("sc.exe", $"stop {ServiceName}", null, progress, ct);
// 1062 = ERROR_SERVICE_NOT_ACTIVE — registered but not running, treat as already stopped.
if (stopExit == 1062)
{
progress.Report("Service was registered but not running.");
return StepResult.Ok();
}
if (stopExit != 0)
return StepResult.Fail($"sc.exe stop failed with exit code {stopExit}");
// Poll until stopped or timeout (up to 30s).
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
var (e, o) = await ProcessRunner.RunAsync("sc.exe", $"query {ServiceName}", null, progress, ct);
if (e != 0 || o.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
{
progress.Report("Service stopped.");
return StepResult.Ok();
}
}
return StepResult.Fail("Service did not stop within 30 seconds.");
}
}

View File

@@ -0,0 +1,42 @@
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteConfigStep : IInstallStep
{
public string Name => "Write Configuration";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
try
{
var workerCfg = new InstallerWorkerConfig
{
DbPath = ctx.DbPath,
SandboxRoot = ctx.SandboxRoot,
LogRoot = ctx.LogRoot,
WorktreeRootStrategy = ctx.WorktreeRootStrategy,
CentralWorktreeRoot = ctx.CentralWorktreeRoot,
QueueBackstopIntervalMs = ctx.QueueBackstopIntervalMs,
SignalRPort = ctx.SignalRPort,
ClaudeBin = ctx.ClaudeBin,
};
workerCfg.Save();
progress.Report("Written worker.config.json");
var uiCfg = new InstallerAppSettings
{
DbPath = ctx.UiDbPath,
SignalRUrl = ctx.SignalRUrl,
};
uiCfg.Save();
progress.Report("Written ui.config.json");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,32 @@
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class WriteInstallManifestStep : IInstallStep
{
public string Name => "Write Install Manifest";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ctx.InstalledVersion))
return Task.FromResult(StepResult.Fail("Installed version is not set — DownloadAndExtractStep must run first."));
try
{
var manifest = new InstallManifest(
Version: ctx.InstalledVersion,
InstallDir: ctx.InstallDirectory,
WorkerDir: Path.Combine(ctx.InstallDirectory, "worker"),
InstalledAt: DateTimeOffset.UtcNow);
InstallManifestStore.Write(ctx.InstallDirectory, manifest);
progress.Report($"Wrote {InstallManifestStore.ManifestPath(ctx.InstallDirectory)}");
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail(ex.Message));
}
}
}

View File

@@ -0,0 +1,81 @@
using System.IO;
using ClaudeDo.Installer.Core;
using Microsoft.Win32;
namespace ClaudeDo.Installer.Steps;
/// <summary>
/// Registers ClaudeDo under <c>HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo</c>
/// so it shows up in Windows "Apps &amp; Features" / "Programs and Features".
/// Also copies the running installer into the install directory so there is an exe
/// for UninstallString to reference after the temp-extracted single-file bundle is gone.
/// </summary>
public sealed class WriteUninstallRegistryStep : IInstallStep
{
internal const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo";
public string Name => "Register in Add/Remove Programs";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var uninstallDir = Path.Combine(ctx.InstallDirectory, "uninstaller");
Directory.CreateDirectory(uninstallDir);
var targetExe = Path.Combine(uninstallDir, "ClaudeDo.Installer.exe");
// Copy the running installer so Apps & Features has a stable exe to launch —
// the single-file temp extract is gone once this process exits.
var sourceExe = Environment.ProcessPath
?? throw new InvalidOperationException("Cannot resolve running installer path.");
try
{
progress.Report("Copying uninstaller binary...");
File.Copy(sourceExe, targetExe, overwrite: true);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to copy uninstaller exe: {ex.Message}");
}
progress.Report("Writing Add/Remove Programs entry...");
try
{
using var key = Registry.LocalMachine.CreateSubKey(UninstallKeyPath, writable: true);
if (key is null)
return StepResult.Fail("Could not open uninstall registry key (permission denied?).");
key.SetValue("DisplayName", "ClaudeDo", RegistryValueKind.String);
key.SetValue("DisplayVersion", ctx.InstallerVersion ?? "0.0.0", RegistryValueKind.String);
key.SetValue("Publisher", "Mika Kuns", RegistryValueKind.String);
key.SetValue("InstallLocation", ctx.InstallDirectory, RegistryValueKind.String);
key.SetValue("UninstallString", $"\"{targetExe}\"", RegistryValueKind.String);
key.SetValue("DisplayIcon", targetExe, RegistryValueKind.String);
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
// Best-effort install size (KB) — scan install dir.
try
{
var sizeKb = (int)(DirectorySizeBytes(ctx.InstallDirectory) / 1024);
key.SetValue("EstimatedSize", sizeKb, RegistryValueKind.DWord);
}
catch { /* best-effort only */ }
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to write uninstall registry: {ex.Message}");
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static long DirectorySizeBytes(string path)
{
long total = 0;
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(file).Length; } catch { /* ignore */ }
}
return total;
}
}

View File

@@ -0,0 +1,373 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══════════════════════════════════════════════════════════
Color palette — mirrored from ClaudeDo.App App.axaml
═══════════════════════════════════════════════════════════ -->
<!-- Accent: Forest Teal -->
<Color x:Key="AccentColor">#3d9474</Color>
<Color x:Key="AccentLightColor">#6bb89e</Color>
<SolidColorBrush x:Key="AccentBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="AccentLightBrush" Color="#6bb89e"/>
<SolidColorBrush x:Key="AccentSubtleBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="AccentSelectedBrush" Color="#263D9474"/>
<!-- Text -->
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#f1f5f9"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#c8d0dc"/>
<SolidColorBrush x:Key="TextMutedBrush" Color="#8892a2"/>
<SolidColorBrush x:Key="TextDimBrush" Color="#6b7688"/>
<!-- Borders & Backgrounds -->
<SolidColorBrush x:Key="BorderSubtleBrush" Color="#3a3f46"/>
<SolidColorBrush x:Key="WindowBgBrush" Color="#1c1e21"/>
<SolidColorBrush x:Key="IslandBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="SidebarBgBrush" Color="#272a2e"/>
<SolidColorBrush x:Key="ContentBgBrush" Color="#272a2e"/>
<!-- Status -->
<SolidColorBrush x:Key="StatusGrayBrush" Color="#475569"/>
<SolidColorBrush x:Key="StatusOrangeBrush" Color="#e67e22"/>
<SolidColorBrush x:Key="StatusGreenBrush" Color="#3d9474"/>
<SolidColorBrush x:Key="StatusRedBrush" Color="#ef4444"/>
<!-- Selection highlights -->
<SolidColorBrush x:Key="SelectionBrush" Color="#333d9474"/>
<SolidColorBrush x:Key="SelectionHoverBrush" Color="#1A3D9474"/>
<SolidColorBrush x:Key="SelectionActiveHoverBrush" Color="#403D9474"/>
<!-- Validation -->
<SolidColorBrush x:Key="ErrorBrush" Color="#ef4444"/>
<!-- ═══════════════════════════════════════════════════════════
Global control styles
═══════════════════════════════════════════════════════════ -->
<!-- Window -->
<Style TargetType="Window">
<Setter Property="Background" Value="{StaticResource WindowBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<!-- UserControl — transparent so window background shows through -->
<Style TargetType="UserControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- ContentControl — transparent container -->
<Style TargetType="ContentControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- TextBlock -->
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Label -->
<Style TargetType="Label">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="0,0,0,2"/>
</Style>
<!-- TextBox -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="SelectionBrush" Value="{StaticResource AccentSubtleBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsReadOnly" Value="True">
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- PasswordBox -->
<Style TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimaryBrush}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Button (default) -->
<Style TargetType="Button">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSubtleBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Accent Button style -->
<Style x:Key="AccentButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="16,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentLightBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox toggle button (dropdown arrow chrome) -->
<ControlTemplate x:Key="ComboBoxToggleButtonTemplate" TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Fill="{StaticResource TextSecondaryBrush}"
Data="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<!-- ComboBox -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Template="{StaticResource ComboBoxToggleButtonTemplate}"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press"/>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" HorizontalAlignment="Left"
TextElement.Foreground="{StaticResource TextPrimaryBrush}"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False"
PopupAnimation="Slide">
<Border x:Name="DropDownBorder"
Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<ScrollViewer SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBoxItem — dark dropdown rows -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- CheckBox -->
<Style TargetType="CheckBox">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- RadioButton -->
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="4,0,0,0"/>
</Style>
<!-- ListBox -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<!-- ListBoxItem -->
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionHoverBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionBrush}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectionActiveHoverBrush}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ProgressBar -->
<Style TargetType="ProgressBar">
<Setter Property="Background" Value="{StaticResource IslandBgBrush}"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="6"/>
</Style>
<!-- ScrollViewer -->
<Style TargetType="ScrollViewer">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Border — default transparent -->
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
</Style>
<!-- ItemsControl -->
<Style TargetType="ItemsControl">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
</Style>
<!-- Separator -->
<Style TargetType="Separator">
<Setter Property="Background" Value="{StaticResource BorderSubtleBrush}"/>
<Setter Property="Height" Value="1"/>
<Setter Property="Margin" Value="0,8"/>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,161 @@
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Steps;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class SettingsViewModel : ObservableObject
{
private readonly InstallContext _context;
private readonly IReleaseClient _releases;
private readonly StopServiceStep _stopService;
private readonly StartServiceStep _startService;
private readonly DownloadAndExtractStep _downloadStep;
private readonly UninstallRunner _uninstallRunner;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
private IInstallerPage? _selectedPage;
[ObservableProperty]
private string? _statusMessage;
[ObservableProperty]
private bool _isStatusError;
[ObservableProperty]
private string _versionLabel = "";
public SettingsViewModel(
PageResolver resolver,
InstallContext context,
IReleaseClient releases,
StopServiceStep stopService,
StartServiceStep startService,
DownloadAndExtractStep downloadStep,
UninstallRunner uninstallRunner)
{
Pages = resolver.SettingsPages;
_context = context;
_releases = releases;
_stopService = stopService;
_startService = startService;
_downloadStep = downloadStep;
_uninstallRunner = uninstallRunner;
_selectedPage = Pages.FirstOrDefault();
VersionLabel = $"Installed: {context.InstalledVersion ?? "?"} Installer: {context.InstallerVersion ?? "?"}";
_ = LoadAllAsync();
}
private async Task LoadAllAsync()
{
foreach (var page in Pages)
await page.LoadAsync();
}
[RelayCommand]
private async Task Save()
{
foreach (var page in Pages)
{
if (!page.Validate())
{
SelectedPage = page;
StatusMessage = $"Validation failed on {page.Title}. Please fix the errors.";
IsStatusError = true;
return;
}
}
foreach (var page in Pages)
await page.ApplyAsync();
var workerCfg = new InstallerWorkerConfig
{
DbPath = _context.DbPath,
SandboxRoot = _context.SandboxRoot,
LogRoot = _context.LogRoot,
WorktreeRootStrategy = _context.WorktreeRootStrategy,
CentralWorktreeRoot = _context.CentralWorktreeRoot,
QueueBackstopIntervalMs = _context.QueueBackstopIntervalMs,
SignalRPort = _context.SignalRPort,
ClaudeBin = _context.ClaudeBin,
};
workerCfg.Save();
var uiCfg = new InstallerAppSettings
{
DbPath = _context.UiDbPath,
SignalRUrl = _context.SignalRUrl,
};
uiCfg.Save();
StatusMessage = "Settings saved.";
IsStatusError = false;
}
[RelayCommand]
private async Task Repair()
{
var confirm = MessageBox.Show(
"Re-download and reinstall the ClaudeDo binaries? Your config and database are NOT affected.",
"Repair ClaudeDo",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
StatusMessage = "Repairing...";
IsStatusError = false;
var progress = new Progress<string>(msg => StatusMessage = msg);
var steps = new IInstallStep[] { _stopService, _downloadStep, _startService };
foreach (var step in steps)
{
var r = await step.ExecuteAsync(_context, progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"{step.Name} failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
}
StatusMessage = "Repair complete.";
}
[RelayCommand]
private async Task Uninstall()
{
var confirm = MessageBox.Show(
"This will remove ClaudeDo AND delete all of your tasks, configuration, and database.\n\nContinue?",
"Uninstall ClaudeDo",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes) return;
var progress = new Progress<string>(msg => StatusMessage = msg);
var r = await _uninstallRunner.RunAsync(progress, CancellationToken.None);
if (!r.Success)
{
StatusMessage = $"Uninstall failed: {r.ErrorMessage}";
IsStatusError = true;
return;
}
MessageBox.Show("ClaudeDo has been removed.", "Uninstall complete",
MessageBoxButton.OK, MessageBoxImage.Information);
Application.Current.Shutdown();
}
[RelayCommand]
private void Close() => Application.Current.Shutdown();
}

View File

@@ -0,0 +1,103 @@
<Window x:Class="ClaudeDo.Installer.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Settings"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:SettingsViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Main content area -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Sidebar -->
<Border Grid.Column="0" Background="{StaticResource SidebarBgBrush}"
Padding="8,12">
<ListBox ItemsSource="{Binding Pages}"
SelectedItem="{Binding SelectedPage}"
HorizontalContentAlignment="Stretch">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Icon}" FontSize="14" Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" FontSize="13"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Separator -->
<Border Grid.Column="1" Width="1" Background="{StaticResource BorderSubtleBrush}"/>
<!-- Page content -->
<Border Grid.Column="2" Padding="24,20">
<ContentControl Content="{Binding SelectedPage.View}"/>
</Border>
</Grid>
<!-- Bottom Bar -->
<Border Grid.Row="1" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Status message / version label -->
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding VersionLabel}" FontSize="11" Opacity="0.7"/>
<TextBlock Text="{Binding StatusMessage}" FontSize="12">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsStatusError}" Value="True">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Uninstall" Margin="0,0,8,0"
Command="{Binding UninstallCommand}"/>
<Button Grid.Column="2" Content="Repair" Margin="0,0,8,0"
Command="{Binding RepairCommand}"/>
<Button Grid.Column="3" Content="Save" Margin="0,0,8,0"
Command="{Binding SaveCommand}"
Style="{StaticResource AccentButton}"/>
<Button Grid.Column="4" Content="Close"
Command="{Binding CloseCommand}"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class SettingsWindow : Window
{
public SettingsWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,85 @@
using System.Linq;
using System.Windows;
using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Pages.InstallPage;
using ClaudeDo.Installer.Pages.WelcomePage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Installer.Views;
public partial class WizardViewModel : ObservableObject
{
private readonly InstallContext _context;
public IReadOnlyList<IInstallerPage> Pages { get; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanGoBack))]
[NotifyPropertyChangedFor(nameof(IsLastPage))]
[NotifyPropertyChangedFor(nameof(NextButtonText))]
[NotifyPropertyChangedFor(nameof(CurrentPage))]
private int _currentPageIndex;
public IInstallerPage CurrentPage => Pages[CurrentPageIndex];
public bool CanGoBack => CurrentPageIndex > 0;
public bool IsLastPage => CurrentPageIndex == Pages.Count - 1;
public string NextButtonText => IsLastPage ? "Install" : "Next \u2192";
[ObservableProperty]
private string? _validationError;
public WizardViewModel(PageResolver resolver, InstallContext context)
{
_context = context;
var all = resolver.WizardPages;
Pages = context.Mode == InstallerMode.Update
? all.Where(p => p is WelcomePageViewModel
|| p is InstallPageViewModel).ToList()
: all;
if (Pages.Count > 0)
_ = InitAsync();
}
private async Task InitAsync()
{
try { await Pages[0].LoadAsync(); }
catch { /* first page loads with defaults on error */ }
}
[RelayCommand]
private async Task GoBack()
{
if (!CanGoBack) return;
CurrentPageIndex--;
await CurrentPage.LoadAsync();
ValidationError = null;
}
[RelayCommand]
private async Task GoNext()
{
if (!CurrentPage.Validate())
{
ValidationError = "Please fix the highlighted errors before continuing.";
return;
}
ValidationError = null;
await CurrentPage.ApplyAsync();
if (CurrentPageIndex < Pages.Count - 1)
{
CurrentPageIndex++;
await CurrentPage.LoadAsync();
}
}
[RelayCommand]
private void Close()
{
Application.Current.Shutdown();
}
}

View File

@@ -0,0 +1,100 @@
<Window x:Class="ClaudeDo.Installer.Views.WizardWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ClaudeDo.Installer.Views"
Title="ClaudeDo Installer"
Icon="/ClaudeTaskSetup.ico"
Width="720" Height="520"
MinWidth="620" MinHeight="460"
WindowStartupLocation="CenterScreen"
Background="{StaticResource WindowBgBrush}"
Foreground="{StaticResource TextPrimaryBrush}"
FontFamily="Segoe UI"
FontSize="13"
d:DataContext="{d:DesignInstance views:WizardViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Step Indicator -->
<Border Grid.Row="0" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,0,0,1"
Padding="20,14">
<ItemsControl ItemsSource="{Binding Pages}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="StepBorder" CornerRadius="4" Padding="10,5" Margin="0,0,6,0"
BorderThickness="1">
<Border.Background>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Background">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.Background>
<Border.BorderBrush>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="BorderBrush">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</Border.BorderBrush>
<TextBlock Text="{Binding Title}" FontSize="12">
<TextBlock.Foreground>
<MultiBinding Converter="{StaticResource StepActiveConverter}" ConverterParameter="Foreground">
<Binding/>
<Binding RelativeSource="{RelativeSource AncestorType=Window}" Path="DataContext.CurrentPage"/>
</MultiBinding>
</TextBlock.Foreground>
</TextBlock>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<!-- Page Content -->
<Border Grid.Row="1" Padding="24,20">
<ContentControl Content="{Binding CurrentPage.View}"/>
</Border>
<!-- Bottom Bar -->
<Border Grid.Row="2" Background="{StaticResource IslandBgBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="0,1,0,0"
Padding="20,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Validation error -->
<TextBlock Grid.Column="0" Text="{Binding ValidationError}"
Foreground="{StaticResource ErrorBrush}"
VerticalAlignment="Center" FontSize="12"
Visibility="{Binding ValidationError, Converter={StaticResource NullToCollapsedConverter}}"/>
<Button Grid.Column="1" Content="Back"
Command="{Binding GoBackCommand}"
IsEnabled="{Binding CanGoBack}"
Margin="0,0,8,0" MinWidth="80"/>
<Button Grid.Column="2" Content="{Binding NextButtonText}"
Command="{Binding GoNextCommand}"
Style="{StaticResource AccentButton}"
MinWidth="100"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace ClaudeDo.Installer.Views;
public partial class WizardWindow : Window
{
public WizardWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="ClaudeDo.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -2,18 +2,20 @@ using System.Collections.ObjectModel;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Views; using ClaudeDo.Ui.Views;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase
{ {
private readonly ListRepository _listRepo; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
private readonly Func<ListEditorViewModel> _listEditorFactory; private readonly Func<ListEditorViewModel> _listEditorFactory;
@@ -26,14 +28,14 @@ public partial class MainWindowViewModel : ViewModelBase
public StatusBarViewModel StatusBar { get; } public StatusBarViewModel StatusBar { get; }
public MainWindowViewModel( public MainWindowViewModel(
ListRepository listRepo, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorkerClient worker, WorkerClient worker,
TaskListViewModel taskList, TaskListViewModel taskList,
TaskDetailViewModel taskDetail, TaskDetailViewModel taskDetail,
StatusBarViewModel statusBar, StatusBarViewModel statusBar,
Func<ListEditorViewModel> listEditorFactory) Func<ListEditorViewModel> listEditorFactory)
{ {
_listRepo = listRepo; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
_listEditorFactory = listEditorFactory; _listEditorFactory = listEditorFactory;
TaskList = taskList; TaskList = taskList;
@@ -48,7 +50,9 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
try try
{ {
var lists = await _listRepo.GetAllAsync(); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
var lists = await listRepo.GetAllAsync();
foreach (var l in lists) foreach (var l in lists)
Lists.Add(new ListItemViewModel(l)); Lists.Add(new ListItemViewModel(l));
} }
@@ -91,10 +95,12 @@ public partial class MainWindowViewModel : ViewModelBase
try try
{ {
await _listRepo.AddAsync(entity); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.AddAsync(entity);
var configEntity = editor.BuildConfig(entity.Id); var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null) if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity); await listRepo.SetConfigAsync(configEntity);
Lists.Add(new ListItemViewModel(entity)); Lists.Add(new ListItemViewModel(entity));
} }
catch (Exception ex) catch (Exception ex)
@@ -107,10 +113,17 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task EditList() private async Task EditList()
{ {
if (SelectedList is null) return; if (SelectedList is null) return;
var existing = await _listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
var config = await _listRepo.GetConfigAsync(existing.Id); ListEntity? existing;
ListConfigEntity? config;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
existing = await listRepo.GetByIdAsync(SelectedList.Id);
if (existing is null) return;
config = await listRepo.GetConfigAsync(existing.Id);
}
var editor = _listEditorFactory(); var editor = _listEditorFactory();
await editor.LoadAgentsAsync(_worker); await editor.LoadAgentsAsync(_worker);
editor.InitForEdit(existing, config); editor.InitForEdit(existing, config);
@@ -125,10 +138,12 @@ public partial class MainWindowViewModel : ViewModelBase
try try
{ {
await _listRepo.UpdateAsync(entity); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.UpdateAsync(entity);
var configEntity = editor.BuildConfig(entity.Id); var configEntity = editor.BuildConfig(entity.Id);
if (configEntity is not null) if (configEntity is not null)
await _listRepo.SetConfigAsync(configEntity); await listRepo.SetConfigAsync(configEntity);
SelectedList.Name = entity.Name; SelectedList.Name = entity.Name;
SelectedList.WorkingDir = entity.WorkingDir; SelectedList.WorkingDir = entity.WorkingDir;
SelectedList.DefaultCommitType = entity.DefaultCommitType; SelectedList.DefaultCommitType = entity.DefaultCommitType;
@@ -146,7 +161,9 @@ public partial class MainWindowViewModel : ViewModelBase
// TODO: confirmation dialog // TODO: confirmation dialog
try try
{ {
await _listRepo.DeleteAsync(SelectedList.Id); using var context = _dbFactory.CreateDbContext();
var listRepo = new ListRepository(context);
await listRepo.DeleteAsync(SelectedList.Id);
Lists.Remove(SelectedList); Lists.Remove(SelectedList);
SelectedList = null; SelectedList = null;
} }

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
@@ -9,18 +10,15 @@ using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public partial class TaskDetailViewModel : ViewModelBase public partial class TaskDetailViewModel : ViewModelBase
{ {
private readonly TaskRepository _taskRepo; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeRepository _worktreeRepo;
private readonly ListRepository _listRepo;
private readonly GitService _git; private readonly GitService _git;
private readonly WorkerClient _worker; private readonly WorkerClient _worker;
private readonly TagRepository _tagRepo;
private readonly SubtaskRepository _subtaskRepo;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
@@ -55,20 +53,18 @@ public partial class TaskDetailViewModel : ViewModelBase
private string? _taskId; private string? _taskId;
private string? _listId; private string? _listId;
private bool _isLoading; private bool _isLoading;
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
// before the previous load finished — prevents torn state on _taskId,
// Subtasks, Tags, etc.
private CancellationTokenSource? _loadCts;
public event Action<string>? TaskChanged; public event Action<string>? TaskChanged;
public TaskDetailViewModel(TaskRepository taskRepo, WorktreeRepository worktreeRepo, public TaskDetailViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, GitService git, WorkerClient worker)
ListRepository listRepo, GitService git, WorkerClient worker, TagRepository tagRepo,
SubtaskRepository subtaskRepo)
{ {
_taskRepo = taskRepo; _dbFactory = dbFactory;
_worktreeRepo = worktreeRepo;
_listRepo = listRepo;
_git = git; _git = git;
_worker = worker; _worker = worker;
_tagRepo = tagRepo;
_subtaskRepo = subtaskRepo;
worker.TaskMessageEvent += OnTaskMessage; worker.TaskMessageEvent += OnTaskMessage;
worker.WorktreeUpdatedEvent += OnWorktreeUpdated; worker.WorktreeUpdatedEvent += OnWorktreeUpdated;
@@ -79,85 +75,121 @@ public partial class TaskDetailViewModel : ViewModelBase
public async Task LoadAsync(string taskId) public async Task LoadAsync(string taskId)
{ {
// Cancel any in-flight load so rapid TaskUpdated events don't race
// on _taskId / Subtasks / Tags. The newest caller wins.
var oldCts = _loadCts;
var cts = new CancellationTokenSource();
_loadCts = cts;
oldCts?.Cancel();
oldCts?.Dispose();
var ct = cts.Token;
_taskId = taskId; _taskId = taskId;
LiveText = ""; LiveText = "";
_formatter = new StreamLineFormatter(); _formatter = new StreamLineFormatter();
var task = await _taskRepo.GetByIdAsync(taskId);
if (task is null) return;
if (AvailableAgents.Count == 0)
{
var agents = await _worker.GetAgentsAsync();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
_isLoading = true;
try try
{ {
_listId = task.ListId; TaskEntity? task;
Title = task.Title; List<TagEntity> tags;
Description = task.Description; List<SubtaskEntity> subtasks;
Result = task.Result;
LogPath = task.LogPath; using (var context = _dbFactory.CreateDbContext())
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(); var taskRepo = new TaskRepository(context);
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath)); task = await taskRepo.GetByIdAsync(taskId, ct);
if (task is null) return;
ct.ThrowIfCancellationRequested();
tags = await taskRepo.GetTagsAsync(taskId, ct);
ct.ThrowIfCancellationRequested();
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(taskId, ct);
} }
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString(); ct.ThrowIfCancellationRequested();
CommitType = task.CommitType;
ModelChoice = task.Model is not null if (AvailableAgents.Count == 0)
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{ {
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath); var agents = await _worker.GetAgentsAsync();
if (match is null) ct.ThrowIfCancellationRequested();
AvailableAgents.AddRange(agents);
OnPropertyChanged(nameof(AvailableAgents));
}
_isLoading = true;
try
{
_listId = task.ListId;
Title = task.Title;
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))
{ {
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath); _formatter = new StreamLineFormatter();
AvailableAgents.Add(match); LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
OnPropertyChanged(nameof(AvailableAgents)); }
StatusText = task.Status.ToString().ToLowerInvariant();
StatusChoice = task.Status.ToString();
CommitType = task.CommitType;
ModelChoice = task.Model is not null
? ListEditorViewModel.ModelIdToDisplay(task.Model)
: "(list default)";
SystemPromptOverride = task.SystemPrompt;
if (task.AgentPath is not null)
{
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
if (match is null)
{
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
AvailableAgents.Add(match);
OnPropertyChanged(nameof(AvailableAgents));
}
SelectedAgent = match;
}
else
{
SelectedAgent = null;
}
Tags.Clear();
foreach (var tag in tags)
Tags.Add(tag);
// Tear down old subtask subscriptions before replacing them.
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Clear();
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
} }
SelectedAgent = match;
} }
else finally
{ {
SelectedAgent = null; _isLoading = false;
} }
Tags.Clear(); await LoadWorktreeAsync(taskId);
var tags = await _taskRepo.GetTagsAsync(taskId);
foreach (var tag in tags)
Tags.Add(tag);
Subtasks.Clear();
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
foreach (var s in subtasks)
{
var vm = SubtaskItemViewModel.From(s);
vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm);
}
} }
finally catch (OperationCanceledException)
{ {
_isLoading = false; // Superseded by a newer LoadAsync — nothing to do.
} }
await LoadWorktreeAsync(taskId);
} }
public async Task SaveAsync() public async Task SaveAsync()
{ {
if (_isLoading || _taskId is null) return; if (_isLoading || _taskId is null) return;
var entity = await _taskRepo.GetByIdAsync(_taskId); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var entity = await taskRepo.GetByIdAsync(_taskId);
if (entity is null) return; if (entity is null) return;
entity.Title = Title; entity.Title = Title;
@@ -172,7 +204,7 @@ public partial class TaskDetailViewModel : ViewModelBase
if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status)) if (Enum.TryParse<Data.Models.TaskStatus>(StatusChoice, true, out var status))
entity.Status = status; entity.Status = status;
await _taskRepo.UpdateAsync(entity); await taskRepo.UpdateAsync(entity);
StatusText = entity.Status.ToString().ToLowerInvariant(); StatusText = entity.Status.ToString().ToLowerInvariant();
TaskChanged?.Invoke(_taskId); TaskChanged?.Invoke(_taskId);
} }
@@ -183,11 +215,15 @@ public partial class TaskDetailViewModel : ViewModelBase
var name = NewTagInput.Trim(); var name = NewTagInput.Trim();
if (string.IsNullOrEmpty(name) || _taskId is null) return; if (string.IsNullOrEmpty(name) || _taskId is null) return;
var tagId = await _tagRepo.GetOrCreateAsync(name); using var context = _dbFactory.CreateDbContext();
await _taskRepo.AddTagAsync(_taskId, tagId); var tagRepo = new TagRepository(context);
var taskRepo = new TaskRepository(context);
var tagId = await tagRepo.GetOrCreateAsync(name);
await taskRepo.AddTagAsync(_taskId, tagId);
Tags.Clear(); Tags.Clear();
var tags = await _taskRepo.GetTagsAsync(_taskId); var tags = await taskRepo.GetTagsAsync(_taskId);
foreach (var tag in tags) foreach (var tag in tags)
Tags.Add(tag); Tags.Add(tag);
@@ -199,7 +235,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveTag(TagEntity tag) private async Task RemoveTag(TagEntity tag)
{ {
if (_taskId is null) return; if (_taskId is null) return;
await _taskRepo.RemoveTagAsync(_taskId, tag.Id); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
await taskRepo.RemoveTagAsync(_taskId, tag.Id);
Tags.Remove(tag); Tags.Remove(tag);
TaskChanged?.Invoke(_taskId); TaskChanged?.Invoke(_taskId);
} }
@@ -217,7 +255,9 @@ public partial class TaskDetailViewModel : ViewModelBase
OrderNum = Subtasks.Count, OrderNum = Subtasks.Count,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
await _subtaskRepo.AddAsync(entity); using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.AddAsync(entity);
var vm = SubtaskItemViewModel.From(entity); var vm = SubtaskItemViewModel.From(entity);
vm.PropertyChanged += OnSubtaskPropertyChanged; vm.PropertyChanged += OnSubtaskPropertyChanged;
Subtasks.Add(vm); Subtasks.Add(vm);
@@ -227,7 +267,11 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task RemoveSubtask(SubtaskItemViewModel item) private async Task RemoveSubtask(SubtaskItemViewModel item)
{ {
if (!string.IsNullOrEmpty(item.Id)) if (!string.IsNullOrEmpty(item.Id))
await _subtaskRepo.DeleteAsync(item.Id); {
using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
await subtaskRepo.DeleteAsync(item.Id);
}
item.PropertyChanged -= OnSubtaskPropertyChanged; item.PropertyChanged -= OnSubtaskPropertyChanged;
Subtasks.Remove(item); Subtasks.Remove(item);
} }
@@ -236,15 +280,25 @@ public partial class TaskDetailViewModel : ViewModelBase
{ {
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return; if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
await _subtaskRepo.UpdateAsync(new SubtaskEntity try
{ {
Id = vm.Id, using var context = _dbFactory.CreateDbContext();
TaskId = _taskId ?? "", var subtaskRepo = new SubtaskRepository(context);
Title = vm.Title, await subtaskRepo.UpdateAsync(new SubtaskEntity
Completed = vm.Completed, {
OrderNum = Subtasks.IndexOf(vm), Id = vm.Id,
CreatedAt = DateTime.UtcNow, TaskId = _taskId ?? "",
}); Title = vm.Title,
Completed = vm.Completed,
OrderNum = Subtasks.IndexOf(vm),
CreatedAt = DateTime.UtcNow,
});
}
catch (Exception ex)
{
// async void must never throw — surface via Debug.
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
}
} }
public void SetAgentFromPath(string path) public void SetAgentFromPath(string path)
@@ -261,6 +315,11 @@ public partial class TaskDetailViewModel : ViewModelBase
public void Clear() public void Clear()
{ {
// Cancel any load in flight so it doesn't resurrect state after Clear.
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = null;
_taskId = null; _taskId = null;
_listId = null; _listId = null;
Title = ""; Title = "";
@@ -284,7 +343,9 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task LoadWorktreeAsync(string taskId) private async Task LoadWorktreeAsync(string taskId)
{ {
var wt = await _worktreeRepo.GetByTaskIdAsync(taskId); using var context = _dbFactory.CreateDbContext();
var wtRepo = new WorktreeRepository(context);
var wt = await wtRepo.GetByTaskIdAsync(taskId);
HasWorktree = wt is not null; HasWorktree = wt is not null;
if (wt is not null) if (wt is not null)
{ {
@@ -341,14 +402,27 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task MergeIntoMainAsync() private async Task MergeIntoMainAsync()
{ {
if (_taskId is null || _listId is null) return; if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId); WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return; if (wt is null || list?.WorkingDir is null) return;
await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName); await _git.MergeFfOnlyAsync(list.WorkingDir, wt.BranchName);
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true); await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Merged);
}
await LoadWorktreeAsync(_taskId); await LoadWorktreeAsync(_taskId);
} }
@@ -356,12 +430,25 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task KeepAsBranchAsync() private async Task KeepAsBranchAsync()
{ {
if (_taskId is null || _listId is null) return; if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId); WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return; if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Kept);
}
await LoadWorktreeAsync(_taskId); await LoadWorktreeAsync(_taskId);
} }
@@ -369,13 +456,26 @@ public partial class TaskDetailViewModel : ViewModelBase
private async Task DiscardAsync() private async Task DiscardAsync()
{ {
if (_taskId is null || _listId is null) return; if (_taskId is null || _listId is null) return;
var wt = await _worktreeRepo.GetByTaskIdAsync(_taskId);
var list = await _listRepo.GetByIdAsync(_listId); WorktreeEntity? wt;
ListEntity? list;
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
wt = await wtRepo.GetByTaskIdAsync(_taskId);
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(_listId);
}
if (wt is null || list?.WorkingDir is null) return; if (wt is null || list?.WorkingDir is null) return;
await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true); await _git.WorktreeRemoveAsync(list.WorkingDir, wt.Path, force: true);
await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true); await _git.BranchDeleteAsync(list.WorkingDir, wt.BranchName, force: true);
await _worktreeRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
using (var context = _dbFactory.CreateDbContext())
{
var wtRepo = new WorktreeRepository(context);
await wtRepo.SetStateAsync(_taskId, Data.Models.WorktreeState.Discarded);
}
await LoadWorktreeAsync(_taskId); await LoadWorktreeAsync(_taskId);
} }
@@ -408,12 +508,28 @@ public partial class TaskDetailViewModel : ViewModelBase
private async void OnWorktreeUpdated(string taskId) private async void OnWorktreeUpdated(string taskId)
{ {
if (taskId != _taskId) return; if (taskId != _taskId) return;
await LoadWorktreeAsync(taskId); try
{
await LoadWorktreeAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
}
} }
private async void OnTaskUpdated(string taskId) private async void OnTaskUpdated(string taskId)
{ {
if (taskId != _taskId) return; if (taskId != _taskId) return;
await LoadAsync(taskId); try
{
await LoadAsync(taskId);
}
catch (Exception ex)
{
// async void must never throw.
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
}
} }
} }

View File

@@ -1,17 +1,19 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.IO; using System.IO;
using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels; namespace ClaudeDo.Ui.ViewModels;
public partial class TaskEditorViewModel : ViewModelBase public partial class TaskEditorViewModel : ViewModelBase
{ {
private readonly SubtaskRepository _subtaskRepo; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string? _description; [ObservableProperty] private string? _description;
@@ -40,9 +42,9 @@ public partial class TaskEditorViewModel : ViewModelBase
public static string[] StatusChoices { get; } = public static string[] StatusChoices { get; } =
["manual", "queued"]; ["manual", "queued"];
public TaskEditorViewModel(SubtaskRepository subtaskRepo) public TaskEditorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{ {
_subtaskRepo = subtaskRepo; _dbFactory = dbFactory;
} }
public async Task LoadAgentsAsync(WorkerClient worker) public async Task LoadAgentsAsync(WorkerClient worker)
@@ -116,7 +118,9 @@ public partial class TaskEditorViewModel : ViewModelBase
WindowTitle = $"Edit Task: {entity.Title}"; WindowTitle = $"Edit Task: {entity.Title}";
Subtasks.Clear(); Subtasks.Clear();
var list = await _subtaskRepo.GetByTaskIdAsync(entity.Id, ct); using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var list = await subtaskRepo.GetByTaskIdAsync(entity.Id, ct);
foreach (var s in list) foreach (var s in list)
Subtasks.Add(SubtaskItemViewModel.From(s)); Subtasks.Add(SubtaskItemViewModel.From(s));
} }
@@ -196,36 +200,42 @@ public partial class TaskEditorViewModel : ViewModelBase
// Persist subtask changes // Persist subtask changes
if (_editId is not null) if (_editId is not null)
{ {
var existing = await _subtaskRepo.GetByTaskIdAsync(taskId); using var context = _dbFactory.CreateDbContext();
var subtaskRepo = new SubtaskRepository(context);
var existing = await subtaskRepo.GetByTaskIdAsync(taskId);
var existingIds = existing.Select(s => s.Id).ToHashSet(); var existingIds = existing.Select(s => s.Id).ToHashSet();
var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet(); var currentIds = Subtasks.Where(s => s.Id != "").Select(s => s.Id).ToHashSet();
// Deleted // Deleted
foreach (var id in existingIds.Except(currentIds)) foreach (var id in existingIds.Except(currentIds))
await _subtaskRepo.DeleteAsync(id); await subtaskRepo.DeleteAsync(id);
// Updated // Updated
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i))) foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)))
{ {
if (vm.Id == "") continue; if (vm.Id == "") continue;
if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted) if (vm.Title != vm.OriginalTitle || vm.Completed != vm.OriginalCompleted)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
else else
{ {
// update order_num if position changed // update order_num if position changed
var orig = existing.FirstOrDefault(e => e.Id == vm.Id); var orig = existing.FirstOrDefault(e => e.Id == vm.Id);
if (orig is not null && orig.OrderNum != idx) if (orig is not null && orig.OrderNum != idx)
await _subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt }); await subtaskRepo.UpdateAsync(new SubtaskEntity { Id = vm.Id, TaskId = taskId, Title = vm.Title, Completed = vm.Completed, OrderNum = idx, CreatedAt = orig.CreatedAt });
} }
} }
} }
// Added (id == "" means new) // Added (id == "" means new)
foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{ {
if (string.IsNullOrWhiteSpace(vm.Title)) continue; using var context = _dbFactory.CreateDbContext();
var newId = Guid.NewGuid().ToString(); var subtaskRepo = new SubtaskRepository(context);
await _subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow }); foreach (var (vm, idx) in Subtasks.Select((v, i) => (v, i)).Where(x => x.v.Id == ""))
{
if (string.IsNullOrWhiteSpace(vm.Title)) continue;
var newId = Guid.NewGuid().ToString();
await subtaskRepo.AddAsync(new SubtaskEntity { Id = newId, TaskId = taskId, Title = vm.Title.Trim(), Completed = vm.Completed, OrderNum = idx, CreatedAt = DateTime.UtcNow });
}
} }
_tcs.TrySetResult(entity); _tcs.TrySetResult(entity);

Some files were not shown because too many files have changed in this diff Show More