Compare commits
61 Commits
9a407bde83
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 33fedc7e26 | |||
|
|
4ca48044db | ||
|
|
611454df1e | ||
|
|
8d61b05179 | ||
|
|
7d0ca45a60 | ||
|
|
36484ed45a | ||
|
|
b7be52a623 | ||
|
|
34ca1b018f | ||
|
|
51a5dcbb73 | ||
|
|
f8f13865d2 | ||
|
|
a064865417 | ||
|
|
9236ca6d45 | ||
|
|
9e1f1370bb | ||
|
|
3b1f148122 | ||
|
|
2b3fe02d8c | ||
|
|
d3b85f2234 | ||
|
|
fc9029de97 | ||
|
|
1c764dae3f | ||
|
|
cfec3297a4 | ||
|
|
6e1d64b489 | ||
|
|
f599f8d0af | ||
|
|
9b928c6217 | ||
| c9e38aef88 | |||
| 66843d242b | |||
| 6afe5959ca | |||
| b623651a5d | |||
|
|
6b1b920149 | ||
|
|
b7a8d78d4a | ||
|
|
b5455a1965 | ||
|
|
5d42438a72 | ||
|
|
2898bec314 | ||
|
|
ac38ea8c34 | ||
|
|
8d2f7e9907 | ||
|
|
da1fe2109a | ||
|
|
5e432a4a27 | ||
|
|
01c29bb6f6 | ||
|
|
12e532718c | ||
|
|
fe913ae5ef | ||
|
|
4fab0481c4 | ||
|
|
0989176127 | ||
|
|
548251841f | ||
|
|
ea32a74baa | ||
|
|
c1e330164e | ||
|
|
5b4af29420 | ||
|
|
d87de152e0 | ||
|
|
b4dc9509cb | ||
|
|
97fb215ce6 | ||
|
|
83d7058b32 | ||
|
|
5603fd458d | ||
|
|
d0c0e2ce1f | ||
|
|
2fc6924dcb | ||
|
|
921e626208 | ||
|
|
c23ed94817 | ||
|
|
2d34afb2e5 | ||
|
|
c0bd46542a | ||
| 7119c8474e | |||
| aea09098e6 | |||
|
|
0498fbae47 | ||
|
|
43a10cff95 | ||
|
|
bd7d5940a2 | ||
|
|
78831b2263 |
174
.gitea/workflows/release.yml
Normal file
174
.gitea/workflows/release.yml
Normal 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
1
.gitignore
vendored
@@ -61,3 +61,4 @@ Desktop.ini
|
|||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
design-time.db
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
2382
docs/superpowers/plans/2026-04-15-installer-download-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
Normal file
1722
docs/superpowers/plans/2026-04-16-efcore-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||||
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal file
253
docs/superpowers/specs/2026-04-16-efcore-migration-design.md
Normal 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
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "8.0.418",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
|
||||||
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
BIN
src/ClaudeDo.App/Assets/ClaudeTask.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 317 B |
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
64
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal file
64
src/ClaudeDo.Data/ClaudeDoDbContext.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal file
15
src/ClaudeDo.Data/ClaudeDoDbContextFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal file
36
src/ClaudeDo.Data/Configuration/ListEntityConfiguration.cs
Normal 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal file
22
src/ClaudeDo.Data/Configuration/TagEntityConfiguration.cs
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal file
75
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal file
298
src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal file
479
src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
src/ClaudeDo.Installer/App.xaml
Normal file
15
src/ClaudeDo.Installer/App.xaml
Normal 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>
|
||||||
132
src/ClaudeDo.Installer/App.xaml.cs
Normal file
132
src/ClaudeDo.Installer/App.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
Normal file
50
src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
Normal 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>
|
||||||
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
BIN
src/ClaudeDo.Installer/ClaudeTaskSetup.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 B |
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal file
37
src/ClaudeDo.Installer/Core/ChecksumVerifier.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/ClaudeDo.Installer/Core/ConfigModels.cs
Normal file
109
src/ClaudeDo.Installer/Core/ConfigModels.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ClaudeDo.Installer/Core/DarkTitleBar.cs
Normal file
29
src/ClaudeDo.Installer/Core/DarkTitleBar.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/ClaudeDo.Installer/Core/IInstallStep.cs
Normal file
20
src/ClaudeDo.Installer/Core/IInstallStep.cs
Normal 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);
|
||||||
16
src/ClaudeDo.Installer/Core/IInstallerPage.cs
Normal file
16
src/ClaudeDo.Installer/Core/IInstallerPage.cs
Normal 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();
|
||||||
|
}
|
||||||
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal 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);
|
||||||
|
}
|
||||||
35
src/ClaudeDo.Installer/Core/InstallContext.cs
Normal file
35
src/ClaudeDo.Installer/Core/InstallContext.cs
Normal 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;
|
||||||
|
}
|
||||||
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallManifest.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal file
48
src/ClaudeDo.Installer/Core/InstallModeDetector.cs
Normal 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 > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ClaudeDo.Installer/Core/InstallerMode.cs
Normal file
8
src/ClaudeDo.Installer/Core/InstallerMode.cs
Normal 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)
|
||||||
|
}
|
||||||
49
src/ClaudeDo.Installer/Core/InstallerService.cs
Normal file
49
src/ClaudeDo.Installer/Core/InstallerService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
Normal file
14
src/ClaudeDo.Installer/Core/NullToCollapsedConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
17
src/ClaudeDo.Installer/Core/PageResolver.cs
Normal file
17
src/ClaudeDo.Installer/Core/PageResolver.cs
Normal 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();
|
||||||
|
}
|
||||||
60
src/ClaudeDo.Installer/Core/ProcessRunner.cs
Normal file
60
src/ClaudeDo.Installer/Core/ProcessRunner.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
85
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
Normal file
37
src/ClaudeDo.Installer/Core/StepIndicatorConverter.cs
Normal 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();
|
||||||
|
}
|
||||||
186
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal file
186
src/ClaudeDo.Installer/Core/UninstallRunner.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
Normal file
101
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageView.xaml
Normal 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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Pages.InstallPage;
|
||||||
|
|
||||||
|
public partial class InstallPageView : UserControl
|
||||||
|
{
|
||||||
|
public InstallPageView() => InitializeComponent();
|
||||||
|
}
|
||||||
163
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
Normal file
163
src/ClaudeDo.Installer/Pages/InstallPage/InstallPageViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
Normal file
17
src/ClaudeDo.Installer/Pages/InstallPage/StepViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
41
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
Normal file
41
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageView.xaml
Normal 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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Pages.PathsPage;
|
||||||
|
|
||||||
|
public partial class PathsPageView : UserControl
|
||||||
|
{
|
||||||
|
public PathsPageView() => InitializeComponent();
|
||||||
|
}
|
||||||
74
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
Normal file
74
src/ClaudeDo.Installer/Pages/PathsPage/PathsPageViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Pages.ServicePage;
|
||||||
|
|
||||||
|
public partial class ServicePageView : UserControl
|
||||||
|
{
|
||||||
|
public ServicePageView() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Pages.UiSettingsPage;
|
||||||
|
|
||||||
|
public partial class UiSettingsPageView : UserControl
|
||||||
|
{
|
||||||
|
public UiSettingsPageView() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Pages.WelcomePage;
|
||||||
|
|
||||||
|
public partial class WelcomePageView : UserControl
|
||||||
|
{
|
||||||
|
public WelcomePageView() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal file
91
src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs
Normal 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
|
||||||
|
}
|
||||||
100
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
100
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal file
32
src/ClaudeDo.Installer/Steps/InitDatabaseStep.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal file
90
src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal file
27
src/ClaudeDo.Installer/Steps/StartServiceStep.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal file
54
src/ClaudeDo.Installer/Steps/StopServiceStep.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal file
42
src/ClaudeDo.Installer/Steps/WriteConfigStep.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal file
32
src/ClaudeDo.Installer/Steps/WriteInstallManifestStep.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal file
81
src/ClaudeDo.Installer/Steps/WriteUninstallRegistryStep.cs
Normal 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 & 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
373
src/ClaudeDo.Installer/Theme/DarkTheme.xaml
Normal file
373
src/ClaudeDo.Installer/Theme/DarkTheme.xaml
Normal 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>
|
||||||
161
src/ClaudeDo.Installer/Views/SettingsViewModel.cs
Normal file
161
src/ClaudeDo.Installer/Views/SettingsViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
103
src/ClaudeDo.Installer/Views/SettingsWindow.xaml
Normal file
103
src/ClaudeDo.Installer/Views/SettingsWindow.xaml
Normal 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>
|
||||||
11
src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
Normal file
11
src/ClaudeDo.Installer/Views/SettingsWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow : Window
|
||||||
|
{
|
||||||
|
public SettingsWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/ClaudeDo.Installer/Views/WizardViewModel.cs
Normal file
85
src/ClaudeDo.Installer/Views/WizardViewModel.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/ClaudeDo.Installer/Views/WizardWindow.xaml
Normal file
100
src/ClaudeDo.Installer/Views/WizardWindow.xaml
Normal 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>
|
||||||
11
src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
Normal file
11
src/ClaudeDo.Installer/Views/WizardWindow.xaml.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Views;
|
||||||
|
|
||||||
|
public partial class WizardWindow : Window
|
||||||
|
{
|
||||||
|
public WizardWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Installer/app.debug.manifest
Normal file
16
src/ClaudeDo.Installer/app.debug.manifest
Normal 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>
|
||||||
16
src/ClaudeDo.Installer/app.manifest
Normal file
16
src/ClaudeDo.Installer/app.manifest
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user