From b79061d6ce8fb294cddf572773d8d8ca86a47c58 Mon Sep 17 00:00:00 2001 From: Victor Zhang Date: Sun, 14 Jun 2026 16:11:46 -0700 Subject: [PATCH] feat(worktree-panel): wire panel to real wtcraft status --json Replace the fixture data source with CliWtcraftClient, which shells out to `wtcraft status --json --repo ` (machine protocol v1) and maps the bare-array output into the panel's WtcraftWorktreeState. - WtcraftCliWorktree: wire DTO for the real snake_case bare-array shape - CliWtcraftClient: async shell-out; degrades to null (git-native fallback) on missing/old wtcraft, non-zero exit, or non-JSON output (old builds print a human table) - IWtcraftClient is now async (GetSnapshotAsync); the fetch runs off the UI thread in RefreshWorktrees and the snapshot is applied on the UI thread - FixtureWtcraftClient kept as a test/demo fallback Alarms and SessionState are deferred (debts D4): status --json carries neither; they await observe --json and a .worktree-session.json writer. Co-Authored-By: Claude Opus 4.8 --- src/App.JsonCodeGen.cs | 1 + src/Models/CliWtcraftClient.cs | 92 ++++++++++++++++++++++++++++++ src/Models/FixtureWtcraftClient.cs | 14 ++--- src/Models/IWtcraftClient.cs | 25 ++++---- src/Models/WtcraftState.cs | 31 ++++++++++ src/ViewModels/Repository.cs | 5 +- src/ViewModels/WorktreesPanel.cs | 34 ++++++----- 7 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 src/Models/CliWtcraftClient.cs diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs index 0beced75f..136709a1b 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -69,6 +69,7 @@ public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializ [JsonSerializable(typeof(Models.RepositorySettings))] [JsonSerializable(typeof(Models.RepositoryUIStates))] [JsonSerializable(typeof(Models.WtcraftSnapshot))] + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] diff --git a/src/Models/CliWtcraftClient.cs b/src/Models/CliWtcraftClient.cs new file mode 100644 index 000000000..922a6ef4c --- /dev/null +++ b/src/Models/CliWtcraftClient.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + /// + /// that shells out to the real wtcraft CLI + /// (wtcraft status --json, machine protocol v1) and maps its bare-array + /// output into a . + /// + /// Any failure degrades to null so the panel falls back to Git-native + /// worktree facts: wtcraft missing, a build too old to support --json + /// (it prints a human table instead), a non-zero exit, or unparseable output. + /// + /// Alarms and session state are not populated here — status --json + /// reports only task-contract and git facts. Reconciled alarms await + /// observe --json; session state lives in .worktree-session.json. + /// + public class CliWtcraftClient : IWtcraftClient + { + public async Task GetSnapshotAsync(string repoPath) + { + if (string.IsNullOrEmpty(repoPath)) + return null; + + try + { + var starter = new ProcessStartInfo + { + // Resolve `wtcraft` on PATH. Unix-first; on Windows this path + // is absent and Process.Start throws, caught below as a + // graceful null. A configurable binary path is a follow-up. + FileName = "/usr/bin/env", + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = repoPath, + }; + starter.ArgumentList.Add("wtcraft"); + starter.ArgumentList.Add("status"); + starter.ArgumentList.Add("--json"); + starter.ArgumentList.Add("--repo"); + starter.ArgumentList.Add(repoPath); + + using var proc = Process.Start(starter); + if (proc == null) + return null; + + var stdout = await proc.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + + if (proc.ExitCode != 0) + return null; + + // Guard against an old wtcraft that ignores --json and prints a + // human table: real machine output is a JSON array. + var head = stdout?.TrimStart(); + if (string.IsNullOrEmpty(head) || head[0] != '[') + return null; + + var rows = JsonSerializer.Deserialize(stdout, JsonCodeGen.Default.ListWtcraftCliWorktree); + if (rows == null) + return null; + + var snapshot = new WtcraftSnapshot { SchemaVersion = 1 }; + foreach (var r in rows) + { + snapshot.Worktrees.Add(new WtcraftWorktreeState + { + Branch = r.Branch ?? string.Empty, + Path = r.Worktree ?? string.Empty, + Stage = r.Stage ?? string.Empty, + Role = r.Role ?? string.Empty, + Agent = r.Agent ?? string.Empty, + Verification = r.VerifyResult ?? string.Empty, + // Alarms / SessionState deferred: status --json carries neither. + }); + } + + return snapshot; + } + catch + { + // wtcraft absent, not executable, timed out, or output unparseable. + return null; + } + } + } +} diff --git a/src/Models/FixtureWtcraftClient.cs b/src/Models/FixtureWtcraftClient.cs index 082b9f258..8b5d26b06 100644 --- a/src/Models/FixtureWtcraftClient.cs +++ b/src/Models/FixtureWtcraftClient.cs @@ -1,5 +1,6 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using Avalonia.Platform; @@ -7,9 +8,8 @@ namespace SourceGit.Models { /// /// backed by a checked-in JSON fixture embedded - /// as an Avalonia resource. This keeps the panel demonstrable without - /// depending on an unreleased local wtcraft protocol, and exercises the same - /// graceful-degradation paths the real client will need. + /// as an Avalonia resource. Kept as a test/demo fallback; production wiring + /// uses . /// public class FixtureWtcraftClient : IWtcraftClient { @@ -18,18 +18,16 @@ public class FixtureWtcraftClient : IWtcraftClient public static readonly Uri FixtureUri = new("avares://SourceGit/Resources/Fixtures/wtcraft-worktrees.json"); - public bool IsAvailable => _snapshot != null; - public FixtureWtcraftClient() { _snapshot = TryLoad(); } - public WtcraftSnapshot GetSnapshot(string repoPath) + public Task GetSnapshotAsync(string repoPath) { - // The fixture is repo-agnostic; a real client would key off repoPath. + // The fixture is repo-agnostic; a real client keys off repoPath. _ = repoPath; - return _snapshot; + return Task.FromResult(_snapshot); } private static WtcraftSnapshot TryLoad() diff --git a/src/Models/IWtcraftClient.cs b/src/Models/IWtcraftClient.cs index 7109b34f1..2acec2575 100644 --- a/src/Models/IWtcraftClient.cs +++ b/src/Models/IWtcraftClient.cs @@ -1,26 +1,23 @@ +using System.Threading.Tasks; + namespace SourceGit.Models { /// /// Abstraction over the wtcraft governance source. /// - /// The MVP only reads checked-in fixtures, but the panel is written against - /// this interface so the real (and still evolving) wtcraft machine protocol - /// can be wired in later without touching the UI. Implementations must never - /// throw from ; they return null to signal - /// "unavailable or unsupported" so callers can degrade to Git-native data. + /// The panel is written against this interface so the real (still evolving) + /// wtcraft machine protocol can be swapped in without touching the UI. + /// Implementations must never throw from ; they + /// return null to signal "unavailable or unsupported" so callers can + /// degrade to Git-native data. /// public interface IWtcraftClient { /// - /// True when a usable snapshot could be loaded. When false, the panel - /// shows Git-native worktree facts only. - /// - bool IsAvailable { get; } - - /// - /// Returns governance state for the given repository, or null - /// when wtcraft is unavailable or its schema is unsupported. + /// Returns governance state for the given repository, or null when + /// wtcraft is unavailable or its output is unsupported. Runs off the UI + /// thread; implementations may shell out to the wtcraft CLI. /// - WtcraftSnapshot GetSnapshot(string repoPath); + Task GetSnapshotAsync(string repoPath); } } diff --git a/src/Models/WtcraftState.cs b/src/Models/WtcraftState.cs index 6d77e967a..b2eeeb83b 100644 --- a/src/Models/WtcraftState.cs +++ b/src/Models/WtcraftState.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; namespace SourceGit.Models { @@ -35,4 +36,34 @@ public class WtcraftSnapshot public int SchemaVersion { get; set; } = 0; public List Worktrees { get; set; } = []; } + + /// + /// One worktree row from `wtcraft status --json` (machine protocol v1). This + /// is the raw wire shape — the command emits a bare JSON array of these — with + /// snake_case field names. maps it into + /// . + /// + /// Alarms and session state are intentionally absent: status --json reports + /// only task-contract + git facts. Reconciled alarms await `observe --json` + /// and per-worktree session state lives in `.worktree-session.json`. + /// + public class WtcraftCliWorktree + { + [JsonPropertyName("repo_root")] public string RepoRoot { get; set; } + [JsonPropertyName("worktree")] public string Worktree { get; set; } + [JsonPropertyName("branch")] public string Branch { get; set; } + [JsonPropertyName("zombie")] public bool Zombie { get; set; } + [JsonPropertyName("locked")] public bool Locked { get; set; } + [JsonPropertyName("contracted")] public bool Contracted { get; set; } + [JsonPropertyName("task_file")] public string TaskFile { get; set; } + [JsonPropertyName("stage")] public string Stage { get; set; } + [JsonPropertyName("role")] public string Role { get; set; } + [JsonPropertyName("agent")] public string Agent { get; set; } + [JsonPropertyName("status")] public string Status { get; set; } + [JsonPropertyName("priority")] public string Priority { get; set; } + [JsonPropertyName("created")] public string Created { get; set; } + [JsonPropertyName("base")] public string Base { get; set; } + [JsonPropertyName("verify_result")] public string VerifyResult { get; set; } + [JsonPropertyName("verified")] public string Verified { get; set; } + } } diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 7ac87ab16..2ef61cb0a 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -473,7 +473,7 @@ public Repository(bool isBare, string path, string gitDir) _settings = Models.RepositorySettings.Get(_gitCommonDir); _uiStates = Models.RepositoryUIStates.Load(GitDir); - _worktreesPanel = new WorktreesPanel(new Models.FixtureWtcraftClient(), this); + _worktreesPanel = new WorktreesPanel(new Models.CliWtcraftClient(), this); } public void Open() @@ -1182,10 +1182,11 @@ public void RefreshWorktrees() { var worktrees = await new Commands.Worktree(FullPath).ReadAllAsync().ConfigureAwait(false); var cleaned = Worktree.Build(FullPath, worktrees); + var snapshot = await _worktreesPanel.FetchSnapshotAsync(FullPath).ConfigureAwait(false); Dispatcher.UIThread.Invoke(() => { Worktrees = cleaned; - _worktreesPanel.Update(FullPath, cleaned); + _worktreesPanel.Update(snapshot, cleaned); }); }); } diff --git a/src/ViewModels/WorktreesPanel.cs b/src/ViewModels/WorktreesPanel.cs index d17378ad2..8e0d3d11e 100644 --- a/src/ViewModels/WorktreesPanel.cs +++ b/src/ViewModels/WorktreesPanel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -74,9 +75,26 @@ public WorktreesPanel(Models.IWtcraftClient wtcraft, Repository repo) _repo = repo; } - public void Update(string repoPath, IReadOnlyList worktrees) + // Runs off the UI thread: may shell out to the wtcraft CLI. Never throws. + public async Task FetchSnapshotAsync(string repoPath) + { + if (_wtcraft == null) + return null; + + try + { + return await _wtcraft.GetSnapshotAsync(repoPath).ConfigureAwait(false); + } + catch + { + return null; + } + } + + // Applies an already-fetched snapshot. Must run on the UI thread (mutates + // the observable Items collection). + public void Update(Models.WtcraftSnapshot snapshot, IReadOnlyList worktrees) { - var snapshot = TryGetSnapshot(repoPath); IsWtcraftAvailable = snapshot != null; Items.Clear(); @@ -89,18 +107,6 @@ public void Update(string repoPath, IReadOnlyList worktrees) IsEmpty = Items.Count == 0; } - private Models.WtcraftSnapshot TryGetSnapshot(string repoPath) - { - try - { - return _wtcraft?.GetSnapshot(repoPath); - } - catch - { - return null; - } - } - private static Models.WtcraftWorktreeState MatchState(Models.WtcraftSnapshot snapshot, Worktree wt) { if (snapshot?.Worktrees == null)