diff --git a/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 new file mode 100644 index 00000000..e6e44edb --- /dev/null +++ b/Tools/windows/Tests/DaemonRoundTrip.Live.Tests.ps1 @@ -0,0 +1,377 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $DaemonExecutable, + [Parameter(Mandatory)] + [string] $ScratchRoot, + [string] $ZigExecutable = $env:GRAPHCODE_ZIG0152, + [string] $WinghosttyInclude = $(if ($env:GRAPHCODE_WINGHOSTTY_ROOT) { + Join-Path $env:GRAPHCODE_WINGHOSTTY_ROOT "include" + }) +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$shellRoot = Join-Path $repoRoot "graphcode-windows" +$DaemonExecutable = (Resolve-Path -LiteralPath $DaemonExecutable).Path +$ZigExecutable = (Resolve-Path -LiteralPath $ZigExecutable).Path +$WinghosttyInclude = (Resolve-Path -LiteralPath $WinghosttyInclude).Path +$ScratchRoot = [IO.Path]::GetFullPath($ScratchRoot) +if (-not (Test-Path -LiteralPath $DaemonExecutable -PathType Leaf)) { + throw "real graphcoded.exe is missing: $DaemonExecutable" +} +if (-not (Test-Path -LiteralPath $ZigExecutable -PathType Leaf)) { + throw "pinned Zig executable is missing: $ZigExecutable" +} +if (-not (Test-Path -LiteralPath $WinghosttyInclude -PathType Container)) { + throw "pinned Winghostty headers are missing: $WinghosttyInclude" +} +if (-not (Test-Path -LiteralPath $ScratchRoot -PathType Container)) { + New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null +} + +$nonce = [guid]::NewGuid().ToString("N") +$runRoot = Join-Path $ScratchRoot "daemon-roundtrip-$nonce" +$supportDirectory = Join-Path $runRoot "support" +$sessionDirectory = Join-Path $supportDirectory "sessions" +$projectPath = Join-Path $runRoot "project" +$spawnProjectPath = Join-Path $runRoot "spawn-target" +$evidenceDirectory = Join-Path $ScratchRoot "daemon-roundtrip-evidence" +$tempDirectory = Join-Path $runRoot "temp" +$zigLocalCache = Join-Path $runRoot "zig-local-cache" +$zigGlobalCache = Join-Path $runRoot "zig-global-cache" +$daemonPipe = "\\.\pipe\graphcode-daemon-roundtrip-$PID-$nonce" +$shutdownEventName = "Local\GraphCode-daemon-roundtrip-shutdown-$PID-$nonce" +$projectNodeSketch = "A1111111-1111-4111-8111-111111111111" +$projectNodeDetails = "B2222222-2222-4222-8222-222222222222" +$sessionMarkerPath = Join-Path $sessionDirectory "$projectNodeSketch.id" +$sessionMarker = "daemon-roundtrip-session-preserved" +$canonicalProjectPath = $projectPath.Replace('\', '/') +$expectedDaemonPath = [IO.Path]::GetFullPath($DaemonExecutable) +$defaultSupportDirectory = Join-Path ([Environment]::GetFolderPath("UserProfile")) ".graphcode" +$successful = $false +$shutdownEvent = $null +$daemonProcess = $null +$runLog = [Collections.Generic.List[string]]::new() + +function Get-DefaultSupportSignature { + if (-not (Test-Path -LiteralPath $defaultSupportDirectory -PathType Container)) { + return "" + } + $records = [Collections.Generic.List[string]]::new() + foreach ($entry in Get-ChildItem -LiteralPath $defaultSupportDirectory -Force -Recurse) { + $relative = $entry.FullName.Substring($defaultSupportDirectory.Length).TrimStart('\') + if ($entry.PSIsContainer) { + $records.Add("D|$relative") + } else { + $hash = (Get-FileHash -LiteralPath $entry.FullName -Algorithm SHA256).Hash + $records.Add("F|$relative|$($entry.Length)|$hash") + } + } + return ($records | Sort-Object) -join "`n" +} + +function Get-OwnedDaemon([int] $processId) { + return @( + Get-CimInstance Win32_Process -ErrorAction Stop | + Where-Object { + $_.Name -ieq "graphcoded.exe" -and + [int]$_.ProcessId -eq $processId -and + [int]$_.ParentProcessId -eq $PID -and + $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath + } + ) +} + +function Start-OwnedDaemon { + [void] $script:shutdownEvent.Reset() + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $expectedDaemonPath + $startInfo.WorkingDirectory = Split-Path -Parent $expectedDaemonPath + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($name in @( + "GRAPHCODE_DAEMON_STARTUP_EVENT", + "GRAPHCODE_DAEMON_HANDOFF_READY_EVENT", + "GRAPHCODE_DAEMON_HANDOFF_TEST_STATE", + "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK", + "GRAPHCODE_DAEMON_SHUTDOWN_EVENT", + "GRAPHCODE_SOCKET")) { + [void] $startInfo.Environment.Remove($name) + } + $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory + $startInfo.Environment["GRAPHCODE_SOCKET"] = $daemonPipe + $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe + $startInfo.Environment["GRAPHCODE_DAEMON_SHUTDOWN_EVENT"] = $shutdownEventName + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "could not start graphcoded.exe" + } + [void] $process.Handle + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + $process.Refresh() + if ($process.HasExited) { + throw "graphcoded.exe exited during startup: $($stderr.Result)" + } + if (@(Get-OwnedDaemon $process.Id).Count -eq 1) { + $script:daemonStdout = $stdout + $script:daemonStderr = $stderr + return $process + } + Start-Sleep -Milliseconds 100 + } + throw "graphcoded.exe did not appear as a child at its exact executable path" +} + +function Stop-OwnedDaemon([Diagnostics.Process] $process) { + if (-not $process) { return } + $process.Refresh() + if (-not $process.HasExited) { + [void] $shutdownEvent.Set() + if (-not $process.WaitForExit(15000)) { + throw "graphcoded.exe did not exit after its named shutdown event" + } + } + $process.Refresh() + if (-not $process.HasExited) { + throw "graphcoded.exe is still running after shutdown" + } + $script:runLog.Add("daemon-exit pid=$($process.Id) code=$($process.ExitCode)") + $script:runLog.Add("daemon-stdout=$($script:daemonStdout.Result.Trim())") + $script:runLog.Add("daemon-stderr=$($script:daemonStderr.Result.Trim())") +} + +function Invoke-ZigTest([string] $label, [string] $filter) { + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PROJECT = $projectPath + $env:GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT = $spawnProjectPath + $env:GRAPHCODE_LOCAL_TEST_SUPPORT = $supportDirectory + $env:ZIG_LOCAL_CACHE_DIR = $zigLocalCache + $env:ZIG_GLOBAL_CACHE_DIR = $zigGlobalCache + $output = @() + $exitCode = 0 + Push-Location $shellRoot + try { + $output = @(& $ZigExecutable test src\DaemonRoundTripTests.zig ` + -target x86_64-windows-msvc -lc -ladvapi32 "-I$WinghosttyInclude" ` + --cache-dir $zigLocalCache --global-cache-dir $zigGlobalCache ` + --test-filter $filter 2>&1) + $exitCode = $LASTEXITCODE + } finally { + Pop-Location + } + $text = $output -join "`n" + [Console]::WriteLine($text) + $logPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-$label.log" + [IO.File]::WriteAllText($logPath, "$text`n", [Text.UTF8Encoding]::new($false)) + $runLog.Add("$label-log=$logPath") + if ($exitCode -ne 0) { + throw "$label daemon round-trip test failed with exit code $exitCode; see $logPath" + } + $count = 0 + if ($text -match '(?m)All\s+(\d+)\s+tests?\s+passed') { + $count = [int]$Matches[1] + } elseif ($text -match '(?m)(\d+)/(\d+)\s+tests?\s+passed') { + $count = [int]$Matches[1] + } + if ($count -lt 1) { + throw "$label did not report a positive executed test count; see $logPath" + } + $runLog.Add("$label-tests=$count") +} + +function Get-SavedProjectGraph { + $projectsDirectory = Join-Path $supportDirectory "projects" + if (-not (Test-Path -LiteralPath $projectsDirectory -PathType Container)) { + return $null + } + foreach ($file in Get-ChildItem -LiteralPath $projectsDirectory -Filter "*.json" -File) { + $graph = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable + if ($graph.project.path -ieq $canonicalProjectPath) { + return [pscustomobject]@{ Path = $file.FullName; Graph = $graph } + } + } + return $null +} + +function Wait-SavedGraph([scriptblock] $predicate, [string] $description) { + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + $saved = Get-SavedProjectGraph + if ($saved -and (& $predicate $saved.Graph)) { + return $saved + } + Start-Sleep -Milliseconds 100 + } + throw "graph was not persisted under the disposable support directory: $description" +} + +function Assert-SketchSessionMarker { + if (-not (Test-Path -LiteralPath $sessionMarkerPath -PathType Leaf) -or + [IO.File]::ReadAllText($sessionMarkerPath) -cne $sessionMarker) { + throw "sketch session-ID mapping changed during promotion or daemon restart" + } +} + +function Assert-MutatedGraph([object] $graph, [int] $fireCount) { + $sketch = @($graph.nodes | Where-Object id -eq $projectNodeSketch) + $details = @($graph.nodes | Where-Object id -eq $projectNodeDetails) + $edges = @($graph.edges) + if ($sketch.Count -ne 1 -or $sketch[0].loopType -ne "turnBased" -or + $sketch[0].title -ne "Persistent sketch") { + return $false + } + if ($details.Count -ne 1 -or $details[0].title -ne "Renamed persisted details" -or + $details[0].checkDescription -ne "Updated verification") { + return $false + } + if ($edges.Count -ne 1 -or $edges[0].from -ne $projectNodeSketch -or + $edges[0].to -ne $projectNodeDetails -or $edges[0].condition -ne "onSuccess" -or + [int]$edges[0].fireCount -ne $fireCount -or + $edges[0].kind -ne "spawn" -or + $edges[0].payloadTransform.script._0 -ne "printf 'round trip'" -or + $edges[0].spawnTargetProjectPath -ne $spawnProjectPath -or + [int]$edges[0].cycleGuard.maxIterations -ne 7 -or + [int]$edges[0].cycleGuard.stopAfterPassesWithoutImprovement -ne 3 -or + $edges[0].cycleGuard.until -ne "test -f done") { + return $false + } + return $true +} + +$defaultSignatureBefore = Get-DefaultSupportSignature +New-Item -ItemType Directory -Force -Path @( + $runRoot, $supportDirectory, $sessionDirectory, $projectPath, $spawnProjectPath, + $evidenceDirectory, $tempDirectory, $zigLocalCache, $zigGlobalCache + ) | Out-Null +[IO.File]::WriteAllText($sessionMarkerPath, $sessionMarker, [Text.UTF8Encoding]::new($false)) +$env:TEMP = $tempDirectory +$env:TMP = $tempDirectory +$env:GRAPHCODE_SUPPORT_DIR = $supportDirectory +$env:GRAPHCODE_DAEMON_PIPE = $daemonPipe +$env:GRAPHCODE_DAEMON_SHUTDOWN_EVENT = $shutdownEventName +$env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "create" +$env:GRAPHCODE_LOCAL_TEST_SUPPORT = $supportDirectory +$shutdownEvent = [Threading.EventWaitHandle]::new( + $false, + [Threading.EventResetMode]::ManualReset, + $shutdownEventName +) + +try { + $homePrefix = $defaultSupportDirectory.TrimEnd('\') + '\' + if ($runRoot.StartsWith($homePrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "disposable daemon state unexpectedly resolves under the default support directory" + } + if ([IO.Path]::GetFullPath($supportDirectory) -eq [IO.Path]::GetFullPath($defaultSupportDirectory)) { + throw "refusing to use the user's default GraphCode support directory" + } + + $daemonProcess = Start-OwnedDaemon + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "create" + Invoke-ZigTest "create" "real daemon mutations survive restart" + $created = Wait-SavedGraph { + param($graph) + $graph.nodes.Count -eq 2 -and $graph.edges.Count -eq 1 + } "node and edge creation" + $edgeId = [string]$created.Graph.edges[0].id + if ([string]::IsNullOrWhiteSpace($edgeId)) { + throw "daemon did not persist a stable edge identity" + } + $runLog.Add("edge-id=$edgeId") + $runLog.Add("support-graph=$($created.Path)") + if (-not $created.Path.StartsWith( + [IO.Path]::GetFullPath($supportDirectory).TrimEnd('\') + '\', + [StringComparison]::OrdinalIgnoreCase)) { + throw "project graph was not stored below the disposable support directory" + } + Stop-OwnedDaemon $daemonProcess + $daemonProcess.Dispose() + $daemonProcess = $null + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "mutex-absent" + Invoke-ZigTest "mutex-after-first-stop" "real daemon lifetime mutex is absent after shutdown" + + $createdGraph = Get-SavedProjectGraph + if (-not $createdGraph -or $createdGraph.Graph.edges.Count -ne 1 -or + $createdGraph.Graph.edges[0].id -ne $edgeId) { + throw "the created graph or edge identity was missing after the first daemon exit" + } + $createdGraph.Graph.edges[0].fireCount = 2 + $seededJson = ConvertTo-Json -InputObject $createdGraph.Graph -Depth 100 -Compress + [IO.File]::WriteAllText($createdGraph.Path, $seededJson, [Text.UTF8Encoding]::new($false)) + $runLog.Add("edge-fire-count-fixture=2") + + $daemonProcess = Start-OwnedDaemon + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "mutate" + $env:GRAPHCODE_DAEMON_ROUNDTRIP_EDGE_ID = $edgeId + Invoke-ZigTest "mutate" "real daemon mutations survive restart" + $mutated = Wait-SavedGraph { param($graph) Assert-MutatedGraph $graph 2 } "accepted edits and promotion" + Assert-SketchSessionMarker + $runLog.Add("support-state=all mutations materialized") + Stop-OwnedDaemon $daemonProcess + $daemonProcess.Dispose() + $daemonProcess = $null + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "mutex-absent" + Invoke-ZigTest "mutex-after-second-stop" "real daemon lifetime mutex is absent after shutdown" + + $daemonProcess = Start-OwnedDaemon + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "verify" + Invoke-ZigTest "reload" "real daemon mutations survive restart" + $reloaded = Wait-SavedGraph { param($graph) Assert-MutatedGraph $graph 2 } "fresh daemon restart state" + Assert-SketchSessionMarker + if ($reloaded.Graph.edges[0].id -ne $edgeId) { + throw "edge identity changed across daemon restart" + } + $runLog.Add("reloaded-edge-id=$($reloaded.Graph.edges[0].id)") + $runLog.Add("sketch-session-marker=$sessionMarker") + Stop-OwnedDaemon $daemonProcess + $daemonProcess.Dispose() + $daemonProcess = $null + $env:GRAPHCODE_DAEMON_ROUNDTRIP_PHASE = "mutex-absent" + Invoke-ZigTest "mutex-after-final-stop" "real daemon lifetime mutex is absent after shutdown" + + $defaultSignatureAfter = Get-DefaultSupportSignature + if ($defaultSignatureAfter -cne $defaultSignatureBefore) { + throw "the user's default ~/.graphcode tree changed during the isolated daemon test" + } + $runLog.Add("default-support=unchanged") + $successful = $true + $resultPath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-summary.txt" + [IO.File]::WriteAllLines($resultPath, $runLog, [Text.UTF8Encoding]::new($false)) + Write-Output "REAL_DAEMON_ROUNDTRIP: PASS" + Write-Output "Accepted mutations: create node, update and rename node, create edge, edit edge, promote sketch" + Write-Output "Restart readback: node identities/configuration and edge id/endpoints/fireCount/transform/spawn/cycle guard" + Write-Output "Mutex: present in each live process and absent after each clean shutdown, using production-derived name" + Write-Output "State: disposable support directory; default ~/.graphcode tree unchanged" + Write-Output "Evidence: $resultPath" +} finally { + if ($daemonProcess -and -not $daemonProcess.HasExited) { + try { Stop-OwnedDaemon $daemonProcess } catch { + Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + } + } + if ($daemonProcess) { $daemonProcess.Dispose() } + if ($shutdownEvent) { $shutdownEvent.Dispose() } + foreach ($name in @( + "GRAPHCODE_DAEMON_ROUNDTRIP_PHASE", + "GRAPHCODE_DAEMON_ROUNDTRIP_PROJECT", + "GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT", + "GRAPHCODE_DAEMON_ROUNDTRIP_EDGE_ID", + "GRAPHCODE_LOCAL_TEST_SUPPORT")) { + Remove-Item -LiteralPath "env:$name" -ErrorAction SilentlyContinue + } + if ($successful) { + Remove-Item -LiteralPath $runRoot -Recurse -Force + } else { + $failurePath = Join-Path $evidenceDirectory "daemon-roundtrip-$nonce-failure.txt" + [IO.File]::WriteAllLines($failurePath, $runLog, [Text.UTF8Encoding]::new($false)) + Write-Output "Failure artifacts retained at $runRoot and $failurePath" + } +} diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 5ee97f0e..7bc245f3 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -549,6 +549,32 @@ Invoke-Native "Daemon client startup tests" { & $zig test src\DaemonClient.zig -target x86_64-windows-msvc -lc -ladvapi32 "-I$include" } finally { Pop-Location } } +Invoke-Native "Daemon round-trip helper executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-worktrees\host-integration" + } + $include = Join-Path $winghosttyRoot "include" + if (-not (Test-Path -LiteralPath $include -PathType Container)) { + throw "Winghostty headers are required for daemon round-trip helper tests." + } + Push-Location $shellRoot + try { + $output = @(& $zig test src\DaemonRoundTripTests.zig ` + -target x86_64-windows-msvc -lc -ladvapi32 "-I$include" ` + --test-filter "daemon round-trip support helper" 2>&1) + $exitCode = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exitCode -ne 0) { + throw "Daemon round-trip helper tests failed with exit code $exitCode" + } + $text = $output -join "`n" + if ($text -notmatch '(?m)All\s+[1-9]\d*\s+tests?\s+passed') { + throw "Daemon round-trip helper test did not report a positive executed test count" + } + } finally { Pop-Location } +} Invoke-Native "Daemon supervisor handoff tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/graphcode-windows/src/DaemonRoundTripTests.zig b/graphcode-windows/src/DaemonRoundTripTests.zig new file mode 100644 index 00000000..6d2d3596 --- /dev/null +++ b/graphcode-windows/src/DaemonRoundTripTests.zig @@ -0,0 +1,352 @@ +const std = @import("std"); +const DaemonClientModule = @import("DaemonClient.zig"); +const DaemonClient = DaemonClientModule.DaemonClient; +const Forms = @import("Forms.zig"); +const GraphModel = @import("GraphModel.zig"); +const SketchPromotion = @import("SketchPromotion.zig"); +const Wire = @import("Wire.zig"); +const Win32 = @import("Win32.zig"); +const c = Win32.c; + +const allocator = std.testing.allocator; +const sketch_id = "A1111111-1111-4111-8111-111111111111"; +const details_id = "B2222222-2222-4222-8222-222222222222"; + +test "daemon round-trip support helper derives scoped global lock name" { + const lock_name = try DaemonClientModule.daemonLockNameFor(allocator, "C:\\graphcode-roundtrip-support"); + defer allocator.free(lock_name); + try std.testing.expect(std.mem.startsWith(u8, lock_name, "Global\\graphcode-daemon-")); +} + +test "real daemon mutations survive restart" { + const phase = try envOwned("GRAPHCODE_DAEMON_ROUNDTRIP_PHASE"); + defer allocator.free(phase); + const support = try envOwned("GRAPHCODE_SUPPORT_DIR"); + defer allocator.free(support); + const project_path = try envOwned("GRAPHCODE_DAEMON_ROUNDTRIP_PROJECT"); + defer allocator.free(project_path); + const expected_edge_id = if (std.mem.eql(u8, phase, "create")) + null + else + try envOwned("GRAPHCODE_DAEMON_ROUNDTRIP_EDGE_ID"); + defer if (expected_edge_id) |id| allocator.free(id); + + try expectDaemonMutex(support, true); + if (std.mem.eql(u8, phase, "create")) { + try createFixture(project_path); + } else if (std.mem.eql(u8, phase, "mutate")) { + try mutateFixture(project_path, expected_edge_id.?); + } else if (std.mem.eql(u8, phase, "verify")) { + try verifyFixture(project_path, expected_edge_id.?); + } else { + return error.InvalidDaemonRoundTripPhase; + } +} + +test "real daemon lifetime mutex is absent after shutdown" { + const support = try envOwned("GRAPHCODE_SUPPORT_DIR"); + defer allocator.free(support); + try expectDaemonMutex(support, false); +} + +const Probe = struct { + model: GraphModel.Model, + response_count: usize = 0, + last_success: ?bool = null, + last_kind: Wire.EventKind = .unknown, + invalid_response: bool = false, + model_error: bool = false, + + fn init() Probe { + return .{ .model = GraphModel.Model.init(allocator) }; + } + + fn receive(context: ?*anyopaque, frame_ptr: [*]const u8, length: usize) callconv(.c) void { + const self: *Probe = @ptrCast(@alignCast(context orelse return)); + const frame = frame_ptr[0..length]; + if (Wire.responseRequestID(frame) != null) { + self.response_count += 1; + self.last_kind = Wire.eventKind(frame); + const parsed = std.json.parseFromSlice( + std.json.Value, + std.heap.page_allocator, + frame, + .{}, + ) catch { + self.invalid_response = true; + return; + }; + defer parsed.deinit(); + if (parsed.value != .object) { + self.invalid_response = true; + return; + } + const kind = parsed.value.object.get("kind") orelse { + self.invalid_response = true; + return; + }; + if (kind != .string or !std.mem.eql(u8, kind.string, "response")) { + self.invalid_response = true; + return; + } + if (parsed.value.object.get("success")) |success| { + if (success != .bool or !success.bool) { + self.invalid_response = true; + return; + } + } + if (self.last_kind != .graph_changed) { + self.invalid_response = true; + return; + } + self.last_success = true; + } + const model_frame = modelCompatibleFrame(allocator, frame) catch { + self.model_error = true; + return; + }; + defer allocator.free(model_frame); + _ = self.model.updateFromFrame(model_frame) catch { + self.model_error = true; + }; + } +}; + +fn modelCompatibleFrame(test_allocator: std.mem.Allocator, frame: []const u8) ![]u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, test_allocator, frame, .{}); + defer parsed.deinit(); + if (parsed.value != .object) return test_allocator.dupe(u8, frame); + const event = parsed.value.object.get("event") orelse return test_allocator.dupe(u8, frame); + if (event != .object) return test_allocator.dupe(u8, frame); + const graph_changed = event.object.get("graphChanged") orelse return test_allocator.dupe(u8, frame); + if (graph_changed != .object) return test_allocator.dupe(u8, frame); + const graph = graph_changed.object.get("_0") orelse return test_allocator.dupe(u8, frame); + const graph_json = try std.json.Stringify.valueAlloc(test_allocator, graph, .{}); + defer test_allocator.free(graph_json); + return std.fmt.allocPrint(test_allocator, "{{\"graphChanged\":{s}}}", .{graph_json}); +} + +fn envOwned(name: []const u8) ![]u8 { + return std.process.getEnvVarOwned(allocator, name) catch error.MissingDaemonRoundTripEnvironment; +} + +fn expectDaemonMutex(support: []const u8, expected: bool) !void { + const name = try DaemonClientModule.daemonLockNameFor(allocator, support); + defer allocator.free(name); + const wide = try std.unicode.utf8ToUtf16LeAllocZ(allocator, name); + defer allocator.free(wide); + const mutex = c.OpenMutexW(c.SYNCHRONIZE, 0, wide.ptr); + if (mutex) |handle| { + defer _ = c.CloseHandle(handle); + if (!expected) return error.DaemonMutexStillExistsAfterShutdown; + std.debug.print("DAEMON_MUTEX: present under production-derived Global name\n", .{}); + return; + } + const code = c.GetLastError(); + if (expected) return error.DaemonMutexMissingWhileProcessRuns; + if (code != c.ERROR_FILE_NOT_FOUND) return error.DaemonMutexProbeFailed; + std.debug.print("DAEMON_MUTEX: absent after process exit\n", .{}); +} + +fn connectProject(client: *DaemonClient, probe: *Probe, path: []const u8) !void { + client.setCallback(Probe.receive, probe); + client.setSubscription(path); + client.connect(); + try client.start(); + const connected_deadline = std.time.milliTimestamp() + 10_000; + while (client.connectionState() != .connected) { + if (std.time.milliTimestamp() >= connected_deadline) return error.DaemonConnectionTimedOut; + std.Thread.sleep(10 * std.time.ns_per_ms); + } + + const before = probe.response_count; + if (client.sendOpenProject(path) == null) return error.OpenProjectQueueRejected; + try waitForAcceptedResponse(client, probe, before); + try std.testing.expect(!probe.model_error); + const canonical_path = try std.mem.replaceOwned(u8, allocator, path, "\\", "/"); + defer allocator.free(canonical_path); + try std.testing.expectEqualStrings(canonical_path, probe.model.currentGraph().?.project.path); +} + +fn waitForAcceptedResponse(client: *DaemonClient, probe: *Probe, before: usize) !void { + const response_deadline = std.time.milliTimestamp() + 10_000; + while (probe.response_count == before) { + client.poll(); + if (std.time.milliTimestamp() >= response_deadline) return error.DaemonResponseTimedOut; + std.Thread.sleep(10 * std.time.ns_per_ms); + } + try std.testing.expect(!probe.invalid_response); + try std.testing.expect(!probe.model_error); + try std.testing.expectEqual(@as(?bool, true), probe.last_success); + try std.testing.expectEqual(Wire.EventKind.graph_changed, probe.last_kind); +} + +fn createFixture(path: []const u8) !void { + var probe = Probe.init(); + defer probe.model.deinit(); + var client = try DaemonClient.init(allocator); + defer client.deinit(); + try connectProject(&client, &probe, path); + + client.sendCreateNodeDraft(path, .{ + .title = "Persistent sketch", + .loop_type = "sketch", + .first_instruction = "Keep this node identity through promotion.", + .node_id = sketch_id, + }); + try waitForAcceptedResponse(&client, &probe, probe.response_count); + try expectNode(&probe, sketch_id, "Persistent sketch", "sketch", null); + + client.sendCreateNodeDraft(path, .{ + .title = "Initial details", + .loop_type = "turnBased", + .check_description = "Initial verification", + .first_instruction = "Persist the accepted node details.", + .node_id = details_id, + }); + try waitForAcceptedResponse(&client, &probe, probe.response_count); + try expectNode(&probe, details_id, "Initial details", "turnBased", "Initial verification"); + + const edge = Forms.EdgeDraft{ + .from = sketch_id, + .to = details_id, + .kind = "spawn", + .condition = "onFailure", + .transform_kind = "script", + .transform_value = "printf 'round trip'", + .cycle_max_iterations = 7, + .cycle_until = "test -f done", + .cycle_stop_after_passes = 3, + .spawn_target_project_path = try envOwned("GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT"), + }; + defer allocator.free(edge.spawn_target_project_path); + client.sendCreateEdgeDraft(path, edge); + try waitForAcceptedResponse(&client, &probe, probe.response_count); + try expectEdge(&probe, null, "onFailure", 0); + std.debug.print("DAEMON_ACCEPTED: node creation and edge creation\n", .{}); +} + +fn mutateFixture(path: []const u8, expected_edge_id: []const u8) !void { + var probe = Probe.init(); + defer probe.model.deinit(); + var client = try DaemonClient.init(allocator); + defer client.deinit(); + try connectProject(&client, &probe, path); + try expectNode(&probe, sketch_id, "Persistent sketch", "sketch", null); + try expectNode(&probe, details_id, "Initial details", "turnBased", "Initial verification"); + const initial_edge = try getSingleEdge(&probe); + const initial_edge_id = try allocator.dupe(u8, initial_edge.id); + defer allocator.free(initial_edge_id); + try std.testing.expectEqualStrings(expected_edge_id, initial_edge_id); + try std.testing.expectEqual(@as(i64, 2), initial_edge.fire_count); + try expectEdge(&probe, initial_edge_id, "onFailure", 2); + + var before = probe.response_count; + client.sendUpdateNodeForm(path, details_id, .{ + .check_description = "Updated verification", + }); + try waitForAcceptedResponse(&client, &probe, before); + try expectNode(&probe, details_id, "Initial details", "turnBased", "Updated verification"); + std.debug.print("DAEMON_ACCEPTED: node details update\n", .{}); + + before = probe.response_count; + client.sendRenameNode(path, details_id, "Renamed persisted details"); + try waitForAcceptedResponse(&client, &probe, before); + try expectNode(&probe, details_id, "Renamed persisted details", "turnBased", "Updated verification"); + + const edge_index = probe.model.findEdgeIndex(initial_edge_id) orelse return error.EditedEdgeMissing; + const current_edge = probe.model.currentGraph().?.edges.items[edge_index]; + const expected = Forms.EdgeConfiguration.fromEdge(current_edge); + var replacement = expected; + replacement.condition = "onSuccess"; + before = probe.response_count; + try client.sendUpdateEdge( + path, + current_edge.id, + current_edge.from, + current_edge.to, + expected, + replacement, + "", + ); + try waitForAcceptedResponse(&client, &probe, before); + try expectEdge(&probe, initial_edge_id, "onSuccess", 2); + std.debug.print("DAEMON_ACCEPTED: edge editing retained count and untouched configuration\n", .{}); + + const sketch_index = probe.model.findNodeIndex(sketch_id) orelse return error.SketchMissing; + if (!probe.model.setSelectedID(sketch_id)) return error.SketchPromotionSelectionMismatch; + const sketch = probe.model.currentGraph().?.nodes.items[sketch_index]; + if (!std.mem.eql(u8, probe.model.selectedNodeID() orelse "", sketch_id)) + return error.SketchPromotionSelectionMismatch; + const project_path = probe.model.currentGraph().?.project.path; + var context = try SketchPromotion.Context.capture(allocator, &probe.model, project_path, "", sketch); + defer context.deinit(allocator); + before = probe.response_count; + if (!try client.sendSketchPromotion(&probe.model, context, .{ .turn = false })) + return error.PromotionQueueRejected; + try waitForAcceptedResponse(&client, &probe, before); + try expectNode(&probe, sketch_id, "Persistent sketch", "turnBased", null); + std.debug.print("DAEMON_ACCEPTED: sketch promotion retained its node/session identity\n", .{}); +} + +fn verifyFixture(path: []const u8, expected_edge_id: []const u8) !void { + var probe = Probe.init(); + defer probe.model.deinit(); + var client = try DaemonClient.init(allocator); + defer client.deinit(); + try connectProject(&client, &probe, path); + try expectNode(&probe, sketch_id, "Persistent sketch", "turnBased", null); + try expectNode(&probe, details_id, "Renamed persisted details", "turnBased", "Updated verification"); + try expectEdge(&probe, expected_edge_id, "onSuccess", 2); + std.debug.print("DAEMON_RELOAD: all five mutations and edge runtime/configuration survived restart\n", .{}); +} + +fn expectNode( + probe: *const Probe, + id: []const u8, + title: []const u8, + loop_type: []const u8, + check_description: ?[]const u8, +) !void { + const graph = probe.model.currentGraph() orelse return error.GraphSnapshotMissing; + const index = probe.model.findNodeIndex(id) orelse return error.ExpectedNodeMissing; + const node = graph.nodes.items[index]; + try std.testing.expectEqualStrings(id, node.id); + try std.testing.expectEqualStrings(title, node.title); + try std.testing.expectEqualStrings(loop_type, node.loop_type); + try std.testing.expectEqualStrings("claudeCode", node.backend); + const first_instruction = if (std.mem.eql(u8, id, sketch_id)) + "Keep this node identity through promotion." + else if (std.mem.eql(u8, id, details_id)) + "Persist the accepted node details." + else + return error.UnexpectedNodeIdentity; + try std.testing.expectEqualStrings(first_instruction, node.first_instruction); + if (check_description) |description| + try std.testing.expectEqualStrings(description, node.check_description); +} + +fn getSingleEdge(probe: *const Probe) !GraphModel.Edge { + const graph = probe.model.currentGraph() orelse return error.GraphSnapshotMissing; + try std.testing.expectEqual(@as(usize, 1), graph.edges.items.len); + return graph.edges.items[0]; +} + +fn expectEdge(probe: *const Probe, expected_id: ?[]const u8, condition: []const u8, fire_count: i64) !void { + const edge = try getSingleEdge(probe); + if (expected_id) |id| try std.testing.expectEqualStrings(id, edge.id); + try std.testing.expectEqualStrings(sketch_id, edge.from); + try std.testing.expectEqualStrings(details_id, edge.to); + try std.testing.expectEqualStrings("spawn", edge.kind); + try std.testing.expectEqualStrings(condition, edge.condition); + try std.testing.expectEqual(fire_count, edge.fire_count); + try std.testing.expect(edge.cycle_guard != null); + try std.testing.expectEqual(@as(?i64, 7), edge.cycle_guard.?.max_iterations); + try std.testing.expectEqualStrings("test -f done", edge.cycle_guard.?.until.?); + try std.testing.expectEqual(@as(?i64, 3), edge.cycle_guard.?.stop_after_passes_without_improvement); + try std.testing.expect(edge.payload_transform == .script); + try std.testing.expectEqualStrings("printf 'round trip'", edge.payload_transform.script); + const spawn_project = try envOwned("GRAPHCODE_DAEMON_ROUNDTRIP_SPAWN_PROJECT"); + defer allocator.free(spawn_project); + try std.testing.expectEqualStrings(spawn_project, edge.spawn_target_project_path.?); +} diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 5acf718d..3bc6563e 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -90,14 +90,14 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe resolved cards with a matching landed, clean, pushed worktree expose separate Reclaim and Keep targets. Reclaim revalidates safety and Keep suppresses the offer for the session. Canvas Reclaim/Keep descendants are now emitted through the UIA provider and invoke the same fail-closed paths. Focused geometry/safety coverage passes, and `Tools\windows\uia-live-gate.ps1` now asserts the live Reclaim/Keep descendants under the Graph fragment (name, non-empty bounds, InvokePattern, and correct RawView/ControlView sibling linkage) via the `windows-shell` CI job (PR #385, run 35422364203, passing) | Validated | | Composite card actions | Open Group, Pilot Once, Arm Schedule | Canvas and sidebar composite menus expose all three actions. Open Group is live-validated; nested creates, edits, deletes, edge changes, pilot, and arm commands use the daemon's authoritative `subGraphCommand` envelope; and Arm Schedule is disabled unless the decoded pilot state is exactly `piloted` | Validated | | Edge presentation | Kind style, fired state, cycle label | Windows retains typed optional cycle guards and derives fired state from authoritative `fireCount`, with missing/null legacy fallback. Raw snapshot JSON reaches the production label formatter for maximum/until/flat-pass and empty-guard summaries; signed/null/malformed inputs, Unicode/long text, owned copies, refresh/composite transitions, and allocation-failure cleanup have executable coverage. Kind styling, selected emphasis, fixed 148-by-20 label bounds, ellipsis, and collision placement are retained. This is exact-string and static-geometry evidence only: full visible wording, the macOS context-menu summary, rendered pixels, and live label evidence remain unverified | Partial | -| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | The guided native form provides endpoint selectors, conditional fields, validation, keyboard traversal, scrolling, and recap. Menu and connector creation now share the full draft sender, retaining condition, payload transform, all optional cycle guards, and spawn target. Project/composite identity and endpoint IDs/titles are owned across the modal; stale model/selection/client subgraph addressing and missing or changed endpoint choices are explicitly rejected without retargeting. The update subscription can still observe another cached project or refresh independently: outgoing creation explicitly addresses the captured project. Registered pure production-path tests inspect the real client's outgoing queue, including cached-project selection, Unicode/quotes, callback-induced graph changes, cancellation, and allocation failures. The reduced connector sender fails the same accepted-draft fixture that the full sender passes. Earlier live evidence only opened the form and checked endpoints/recap before cancellation; this fix has no new live modal/keyboard, daemon acceptance, persistence, or cross-platform parity evidence | Partial | +| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | The guided native form provides endpoint selectors, conditional fields, validation, keyboard traversal, scrolling, and recap. Menu and connector creation now share the full draft sender, retaining condition, payload transform, all optional cycle guards, and spawn target. Project/composite identity and endpoint IDs/titles are owned across the modal; stale model/selection/client subgraph addressing and missing or changed endpoint choices are explicitly rejected without retargeting. The update subscription can still observe another cached project or refresh independently: outgoing creation explicitly addresses the captured project. Registered pure production-path tests inspect the real client's outgoing queue, including cached-project selection, Unicode/quotes, callback-induced graph changes, cancellation, and allocation failures. The reduced connector sender fails the same accepted-draft fixture that the full sender passes. Earlier live evidence only opened the form and checked endpoints/recap before cancellation; at that point, daemon acceptance and persistence were also unproven. The added headless harness starts the real `graphcoded.exe` with isolated support state; production `sendCreateEdgeDraft` receives a correlated V2 `graphChanged` response, and daemon save/reload preserves edge identity, endpoints, condition, transform, spawn target, and cycle guard. Native modal/keyboard, UIA, and macOS parity evidence remain absent. | Partial | | Custody child creation | New Child Node on unresolved parents, inherited editable backend, daemon-owned custody | Project-canvas/sidebar node menus consume the tested unresolved-only item plan (5119). Owned popup target/settings/child-only exact-project worktree snapshots feed the existing guarded node form and template/attachment continuation. Original popup context is checked before normal initial selection; final guards never reselect, reject project/composite/address drift or deleted/resolved/type/backend-changed parents, and allow rename/reorder/unresolved progress. Tests exercise the production initializer/transfer boundary and real data-only client queue, including cached B while observing A, root-versus-composite same IDs, UUID/null wire fields, cancellation and allocation failures. `createdBy` produces one create command: GraphStore owns the fired handoff/report-back memo and normal startup. No new protocol or parent/session mutation. Native display/action results, overview right-click, daemon acceptance/persistence, and inherited downstream-send failure handling remain unverified. The merged ordinary-creation path now retains the owned worktree snapshots whose evidence is recorded in Node creation sheet, while custody retains its pre-popup owned snapshot; template backend settings remain a separate residual | Partial | -| Edge editing | Preserve identity/configuration and refuse stale edits | Windows uses an owned modal snapshot and one checked `updateEdge`, retaining endpoints, current fireCount, transform/spawn settings and unchanged optional legacy guards. Root/direct-composite scope and current cache are rechecked without treating observation subscription as authorization. Existing serialized daemon commands, blocking, parent roll-up and root publication remain authoritative; deeper new edit wrappers are refused. Production-helper regressions exercise actual Wire JSON/unstarted queues, foreign scopes, refresh, cancellation and allocation cleanup. Edit-only live/submit capture reads complete Unicode text and blocks acceptance on per-field read/allocation errors; injected-reader and initializer tests show no controls. In-memory Swift store tests cover CAS, kind/blocking rules, root publication, preview refusal and serialized legacy-child writeback followed by an edit that retains runtime progress. This is not native editing, daemon acceptance/persistence or macOS runtime evidence; macOS sources expose creation-time configuration and delete, not an existing edit UI | Partial | -| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, scrolling, and a live-updating recap. The native Branch picker offers inspection choices plus “This folder”; existing tests cover its empty state and selected binding, and earlier gate evidence covers the recap, Branch picker, and distinct Attach control. Staging now reserves a uniquely named file exclusively: the production append/removal regression first demonstrated A/B/C → remove B → add D overwriting C's bytes, then passed with C's original path and bytes preserved. Offline in-file coverage also checks template-restored/legacy files, preexisting destination collisions, partial-write rollback, allocation failures without orphan copies, invalid inputs, exactly eight files and the ninth rejection, and the exact 10 MiB/+1-byte boundary. Existing attachment DTO/wire tests remain passing; these are no-window helper/filesystem results, not OS input or daemon persistence evidence. Creation choices now own an exact-project snapshot of inspected path/branch strings across cache/model replacement; baseline aliasing and foreign-cache regressions fail before the fix and pass afterward. Noninteractive production-projection tests cover snapshot lifetime, projection without a loaded graph, and allocation-failure cleanup. Separate pure initializer/builder-to-Wire tests verify exact non-default repository/id/path/branch fields, initial/current/template-restored selection, and “This folder” clearing with available choices; legacy empty-choice hidden binding remains unchanged. This does not establish actual native non-default selection, a Git-inspection-to-creation flow, or daemon save/reload. **Still partial:** actual inspected nondefault binding and project-scoped ownership across pumped modals remain unproved; New branch and remote/global branch visibility differ from macOS; authentic OS picker acceptance/cancellation, clipboard paste/drop, and native-input → request → daemon save/reload evidence remain outstanding. Per-file removed-file reclamation and legacy unguarded cleanup remain separate residuals. Creation now retains the original project/composite context and validates after modal teardown, before attachment transfer, across normal, template-load-error, empty-library, and template-loop submissions without switching selection or client scope. Bounded offline production-boundary RED/GREEN and owned temporary-file tests cover stale-scope refusal, loaded-project closure, same-path refresh/promotion, path-only starts, valid empty composites, template continuation, allocation unwinding, and checked cleanup failures. Guarded staging reserves its attachment-directory leaf exclusively; cancellation/refusal and zero-reference acceptance abandon only that owned leaf, while nonzero accepted attachment bytes survive. Zero-reference drafts preserve existing empty-ID/wire behavior. These are helper/model/unstarted-client-queue proofs, not live modal, OS picker, or daemon-persistence evidence; path-only external disappearance and downstream send failure remain limitations | Partial | -| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. Edit Details uses `NativeForms.update` and `sendUpdateNodeForm`; project/node identity is owned across the modal loop and re-resolved afterward, changed strings are compared with an owned initial snapshot, and numeric plus clear-versus-unchanged semantics are preserved. Production-helper tests cover mutation, unchanged/changed typed fields, cancellation, clearing, allocation failures, and the former borrowed-baseline lifetime bug. The green Windows shell CI run 36489223062 opened the live Rename Loop dialog, verified its explanation, Title label, and prefilled current title, typed a replacement title and submitted it with Return, and observed the dialog close. The same run found Rename and Edit Details in live plain, composite, and unwired node popups. It did not verify the renamed title in the graph/sidebar/model after submission and did not open, cancel, or submit Edit Details. macOS runtime evidence did verify a root rename reaching overview/sidebar, but reproduced a nested Rename action that showed no dialog and did not exercise typed retype. Presence plus dialog closure is not end-to-end update parity, so app-level rename result and Edit Details behavior remain residuals and the row stays `Partial` | Partial | +| Edge editing | Preserve identity/configuration and refuse stale edits | Windows uses an owned modal snapshot and one checked `updateEdge`, retaining endpoints, current fireCount, transform/spawn settings and unchanged optional legacy guards. Root/direct-composite scope and current cache are rechecked without treating observation subscription as authorization. Existing serialized daemon commands, blocking, parent roll-up and root publication remain authoritative; deeper new edit wrappers are refused. Production-helper regressions exercise actual Wire JSON/unstarted queues, foreign scopes, refresh, cancellation and allocation cleanup. Edit-only live/submit capture reads complete Unicode text and blocks acceptance on per-field read/allocation errors; injected-reader and initializer tests show no controls. In-memory Swift store tests cover CAS, kind/blocking rules, root publication, preview refusal and serialized legacy-child writeback followed by an edit that retains runtime progress. This does not establish native editing or macOS runtime evidence; macOS sources expose creation-time configuration and delete, not an existing edit UI. The added headless real-daemon test confirms production `sendUpdateEdge` receives a correlated V2 `graphChanged` response and a fresh daemon reload preserves edge ID, endpoints, `fireCount=2`, transform, spawn target, condition, and cycle guard. | Partial | +| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, scrolling, and a live-updating recap. The native Branch picker offers inspection choices plus “This folder”; existing tests cover its empty state and selected binding, and earlier gate evidence covers the recap, Branch picker, and distinct Attach control. Staging now reserves a uniquely named file exclusively: the production append/removal regression first demonstrated A/B/C → remove B → add D overwriting C's bytes, then passed with C's original path and bytes preserved. Offline in-file coverage also checks template-restored/legacy files, preexisting destination collisions, partial-write rollback, allocation failures without orphan copies, invalid inputs, exactly eight files and the ninth rejection, and the exact 10 MiB/+1-byte boundary. Existing attachment DTO/wire tests remain passing; these are no-window helper/filesystem results, not OS input or daemon persistence evidence. Creation choices now own an exact-project snapshot of inspected path/branch strings across cache/model replacement; baseline aliasing and foreign-cache regressions fail before the fix and pass afterward. Noninteractive production-projection tests cover snapshot lifetime, projection without a loaded graph, and allocation-failure cleanup. Separate pure initializer/builder-to-Wire tests verify exact non-default repository/id/path/branch fields, initial/current/template-restored selection, and “This folder” clearing with available choices; legacy empty-choice hidden binding remains unchanged. This does not establish actual native non-default selection or a Git-inspection-to-creation flow. **Still partial:** actual inspected nondefault binding and project-scoped ownership across pumped modals remain unproved; New branch and remote/global branch visibility differ from macOS; authentic OS picker acceptance/cancellation and clipboard paste/drop remain outstanding; native-input evidence remains outstanding. Per-file removed-file reclamation and legacy unguarded cleanup remain separate residuals. Creation now retains the original project/composite context and validates after modal teardown, before attachment transfer, across normal, template-load-error, empty-library, and template-loop submissions without switching selection or client scope. Bounded offline production-boundary RED/GREEN and owned temporary-file tests cover stale-scope refusal, loaded-project closure, same-path refresh/promotion, path-only starts, valid empty composites, template continuation, allocation unwinding, and checked cleanup failures. Guarded staging reserves its attachment-directory leaf exclusively; cancellation/refusal and zero-reference acceptance abandon only that owned leaf, while nonzero accepted attachment bytes survive. Zero-reference drafts preserve existing empty-ID/wire behavior. These are helper/model/unstarted-client-queue proofs, not live modal, OS picker, or daemon-persistence evidence; path-only external disappearance and downstream send failure remain limitations.. A headless real-daemon round-trip sends production `sendCreateNodeDraft`, receives a correlated V2 `graphChanged` response, and confirms node identity/configuration in persisted graph state and after a fresh daemon restart. This does not establish native input, OS picker acceptance, UIA, or macOS parity. | Partial | +| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. Edit Details uses `NativeForms.update` and `sendUpdateNodeForm`; project/node identity is owned across the modal loop and re-resolved afterward, changed strings are compared with an owned initial snapshot, and numeric plus clear-versus-unchanged semantics are preserved. Production-helper tests cover mutation, unchanged/changed typed fields, cancellation, clearing, allocation failures, and the former borrowed-baseline lifetime bug. The green Windows shell CI run 36489223062 opened the live Rename Loop dialog, verified its explanation, Title label, and prefilled current title, typed a replacement title and submitted it with Return, and observed the dialog close. The same run found Rename and Edit Details in live plain, composite, and unwired node popups. It did not verify the renamed title in the graph/sidebar/model after submission and did not open, cancel, or submit Edit Details. macOS runtime evidence did verify a root rename reaching overview/sidebar, but reproduced a nested Rename action that showed no dialog and did not exercise typed retype. Presence plus dialog closure is not end-to-end update parity, so app-level rename result and Edit Details behavior remain residuals and the row stays `Partial`. Separately, the headless real-daemon test exercises production `sendUpdateNodeForm` and `sendRenameNode`; correlated V2 `graphChanged` replies confirm acceptance, and a fresh daemon reload retains the node ID with updated check description and renamed title. This covers daemon acceptance/persistence only, not the live UI update result; native editor/rename interaction, cancellation, UIA, and returned app-level dispatch remain unverified. | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | | Canvas context menu | Folder actions on background; complete node/edge actions | Project backgrounds expose Worktrees, Project Settings, and Explorer routes with captured project context; unavailable non-filesystem actions are disabled and global scope omits them. Create Edge remains present but disabled with fewer than two loops. Resolved node menus omit Stop; composite/template/unwired actions and the absence of Message/Memo are preserved. Tests inspect the same native HMENU builder used by `TrackPopupMenu`. The green Windows shell CI run 36489223062 opened and read the real native plain, composite, and unwired loop popups, required their state-specific labels and absences, verified disabled Arm Schedule for an unpiloted composite, and dismissed each menu without losing the UIA tree. It also read local and remote project popups, including disabled unavailable Move Project and omission of local-only actions for a remote project. macOS runtime evidence sampled Composite, Main, and background menus but did not open its edge menu. Neither runtime drove a destructive confirmation or edge popup, and Windows CI did not invoke node/background menu results, New Child, import/export, custody creation, or promotion. Those actions are part of this row's explicit node/edge contract, so popup presence and enablement alone do not justify promotion | Partial | -| Sketch promotion | Promote a sketch to Goal, Turn, or Timed without replacing its identity or session | Canvas and sidebar context menus use one production submenu plan and focused native forms. Owned popup/form snapshots reject stale scope, selection, type, and deletion; initial selection of a different cached project remains supported independently of the observation subscription. Filtered pure tests exercise the actual App selection adapter, typed native-form builder, and unstarted DaemonClient outbound queue, including composite addressing and allocation failures. Three emitted fixtures decode as existing Swift `promoteNode` variants, and the full Windows application builds ReleaseSafe. A native Win32 test attaches the production node popup to a hidden HWND and checks the Goal/Turn/Timed submenu's presence, IDs, labels, and enabled states through the actual HMENU; it does not show or select the popup. Local command construction/queueing and this native menu state are proven, but native keyboard interaction, UIA, app launch, and real-daemon acceptance/persistence remain unexercised | Partial | +| Sketch promotion | Promote a sketch to Goal, Turn, or Timed without replacing its identity or session | Canvas and sidebar context menus use one production submenu plan and focused native forms. Owned popup/form snapshots reject stale scope, selection, type, and deletion; initial selection of a different cached project remains supported independently of the observation subscription. Filtered pure tests exercise the actual App selection adapter, typed native-form builder, and unstarted DaemonClient outbound queue, including composite addressing and allocation failures. Three emitted fixtures decode as existing Swift `promoteNode` variants, and the full Windows application builds ReleaseSafe. A native Win32 test attaches the production node popup to a hidden HWND and checks the Goal/Turn/Timed submenu's presence, IDs, labels, and enabled states through the actual HMENU; it does not show or select the popup. Local command construction/queueing and this native menu state are proven; native keyboard interaction, UIA, and app launch remain unexercised. The added headless real-daemon test receives a correlated acceptance response for production `sendSketchPromotion`, reloads the same node ID as `turnBased`, and confirms a synthetic session-ID marker file at the production `/sessions/.id` path remains unchanged. An active backend-session run remains unexercised. | Partial | ## Quick Chats