diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..db548ae2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.swift text eol=lf diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4387a273..b0438c71 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,9 @@ -- +RED: -> +GREEN: -> pass +REGRESSION: -> pass ## Checklist @@ -20,3 +22,4 @@ - [ ] I have signed off my commits (`git commit -s`) per the DCO - [ ] Tests pass locally (`make test`) - [ ] Code follows the existing style (`make check`) +- [ ] I added the test/contract before the implementation and observed the intended RED failure diff --git a/.github/workflows/macos-shared-regression.yml b/.github/workflows/macos-shared-regression.yml new file mode 100644 index 00000000..8bd067ac --- /dev/null +++ b/.github/workflows/macos-shared-regression.yml @@ -0,0 +1,47 @@ +name: macOS shared Swift regression + +on: + workflow_dispatch: + pull_request: + paths: + - "GraphcodeKit/**" + - "graphcode/**" + - "graphcoded/**" + - "graphcode-cli/**" + - "investigation/spikes/swift-portable/**" + - "Project.swift" + - "Tuist.swift" + - "Tuist/**" + - "mise.toml" + - "Makefile" + - ".gitmodules" + - "ThirdParty/**" + - "**/Package.swift" + - "**/Package.resolved" + - ".swift-format" + - ".swiftlint.yml" + - ".github/workflows/macos-shared-regression.yml" + +permissions: + contents: read + +jobs: + macos: + runs-on: macos-14 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: recursive + fetch-depth: 0 + - name: Prepare portable Swift sources + run: python3 Tools/portable-prepare.py + - name: Install pinned mise tools + run: | + brew install mise + mise install + - name: Validate shared portable Swift package + run: mise exec -- swift test --package-path investigation/spikes/swift-portable + - name: Validate macOS app, daemon, and CLI + run: mise exec -- make test + - name: Validate macOS lint and formatting + run: mise exec -- make check diff --git a/.github/workflows/tdd-evidence.yml b/.github/workflows/tdd-evidence.yml new file mode 100644 index 00000000..a51a752c --- /dev/null +++ b/.github/workflows/tdd-evidence.yml @@ -0,0 +1,18 @@ +name: TDD evidence + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + validate: + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Require RED, GREEN, and REGRESSION evidence + shell: pwsh + run: ./Tools/tdd/Test-TddEvidence.ps1 -EventPath $env:GITHUB_EVENT_PATH diff --git a/.github/workflows/windows-hardening.yml b/.github/workflows/windows-hardening.yml new file mode 100644 index 00000000..7b33dcfd --- /dev/null +++ b/.github/workflows/windows-hardening.yml @@ -0,0 +1,81 @@ +name: Windows release hardening + +on: + workflow_dispatch: + inputs: + environment_target: + description: "Workspace-relative path to the owned environment harness" + required: false + type: string + schedule: + - cron: "17 3 * * 1" + pull_request: + paths: + - ".github/workflows/windows-hardening.yml" + - "Package.swift" + - "Package.resolved" + - "MailroomKit/**" + - "GraphcodeKit/**" + - "graphcode-cli/**" + - "graphcoded/**" + - "graphcode-windows/**" + - "windows-tests/**" + - "Tools/windows/**" + - "investigation/windows-implementation-plan.md" + - "Tools/windows/validation-matrix.md" + +permissions: + contents: read + +jobs: + deterministic: + name: "Deterministic hardening (${{ matrix.os }}, ${{ matrix.powershell }})" + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + powershell: [pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Run hardening contract + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task hardening + - name: Verify runner contract + shell: pwsh + run: ./Tools/windows/Tests/ValidationRunner.Tests.ps1 + + full-pinned: + name: "Full pinned Windows validation" + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + - name: Bootstrap exact Windows dependencies + shell: pwsh + run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers + - name: Run complete release gate + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E + - name: Run real hardening matrix after release products + shell: pwsh + env: + GRAPHCODE_HARDENING_TARGET: ${{ github.workspace }}\Tools\windows\Tests\EnvironmentFixture.ps1 + run: ./Tools/windows/Tests/Hardening.Tests.ps1 -Environment -SkipTrayLive + + environment: + name: "Gated environment hardening" + if: github.event_name == 'workflow_dispatch' && inputs.environment_target != '' + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Run explicitly selected environment tier + shell: pwsh + env: + GRAPHCODE_HARDENING_TARGET: ${{ inputs.environment_target }} + run: ./Tools/windows/Tests/Hardening.Tests.ps1 -Environment -SchemaOnly diff --git a/.github/workflows/windows-port-validation.yml b/.github/workflows/windows-port-validation.yml new file mode 100644 index 00000000..f9839b93 --- /dev/null +++ b/.github/workflows/windows-port-validation.yml @@ -0,0 +1,44 @@ +name: Windows port validation + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/windows-port-validation.yml" + - "Package.swift" + - "Package.resolved" + - "MailroomKit/**" + - "GraphcodeKit/**" + - "graphcode-cli/**" + - "graphcoded/**" + - "graphcode-windows/**" + - "windows-tests/**" + - "Tools/windows/**" + - "investigation/**" + +permissions: + contents: read + +jobs: + windows-spikes: + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + + - name: Bootstrap exact Windows dependencies + shell: pwsh + run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers + + - name: Verify validation runner contract + shell: pwsh + run: ./Tools/windows/Tests/ValidationRunner.Tests.ps1 + + - name: Run Windows port validation + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E diff --git a/.github/workflows/windows-shell.yml b/.github/workflows/windows-shell.yml new file mode 100644 index 00000000..7b65b72f --- /dev/null +++ b/.github/workflows/windows-shell.yml @@ -0,0 +1,56 @@ +name: Windows shell validation + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/windows-shell.yml" + - "Package.swift" + - "Package.resolved" + - "MailroomKit/**" + - "GraphcodeKit/**" + - "graphcode-cli/**" + - "graphcoded/**" + - "windows-tests/**" + - "graphcode-windows/**" + - "Tools/windows/bootstrap.ps1" + - "Tools/windows/windows-shell.ps1" + - "Tools/windows/uia-live-gate.ps1" + - "Tools/windows/validate.ps1" + - "Tools/windows/Tests/WindowsShell.Tests.ps1" + - "Tools/windows/Tests/TrayDaemon.Tests.ps1" + - "Tools/windows/Tests/TrayLive.Tests.ps1" + - "Tools/windows/Tests/ValidationRunner.Tests.ps1" + - "Tools/windows/package.ps1" + - "Tools/windows/PACKAGING.md" + - "Tools/windows/Tests/Packaging.Tests.ps1" + - "Tools/windows/Tests/Packaging.RealLifecycle.Tests.ps1" + - "graphcode-windows/package-metadata.json" + - "investigation/windows-implementation-plan.md" + +permissions: + contents: read + +jobs: + windows-shell: + runs-on: windows-2022 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + + - name: Bootstrap exact Windows dependencies + shell: pwsh + run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers + + - name: Verify shell contracts and pinned-provider smoke + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task windows-shell -SkipTrayLive + + - name: Validate release packaging + shell: pwsh + run: ./Tools/windows/validate.ps1 -Task packaging diff --git a/.gitignore b/.gitignore index 17d8c5bd..2f2352e9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,13 @@ xcuserdata/ # SwiftPM .swiftpm/ +# Zig build outputs +.zig-cache/ +zig-out/ +.graphcode-tools/ +.ci-tools/ +.ci-providers/ + # mise .mise.local.toml diff --git a/GraphcodeKit/Sources/DaemonBootstrap.swift b/GraphcodeKit/Sources/DaemonBootstrap.swift index f7287510..efb38873 100644 --- a/GraphcodeKit/Sources/DaemonBootstrap.swift +++ b/GraphcodeKit/Sources/DaemonBootstrap.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(Darwin) + /// Installs the helpers a shipped `graphcode.app` carries inside itself — `graphcoded` and /// `zmx` — and loads the daemon, so dragging the app to `/Applications` is the whole /// installation. @@ -317,3 +319,400 @@ public enum DaemonBootstrap { return process.terminationStatus } } + +#else + +/// Windows helper installation and per-user startup registration. +import WinSDK + +public enum DaemonBootstrap { + public enum Outcome: Equatable { + case notPackaged + case upToDate + case installed + case failed(String) + } + + private static let helpers = ["graphcoded.exe", "graphcode.exe"] + private static let versionFile = ".graphcode-package.version" + private static let endpointGenerationFile = ".graphcode-endpoint-generation" + private static let runtimeFilesFile = ".graphcode-runtime-files" + + public static func installIfNeeded() -> Outcome { + guard let bundled = bundledHelperDirectory(in: .main) else { + return .notPackaged + } + + let destination = SupportDirectory.binDirectory + do { + guard !bundledRuntimeFiles(in: bundled).isEmpty else { + throw StartupManagerError.missingRuntimeFiles + } + let manager = try WindowsStartupManager( + daemonURL: destination.appendingPathComponent("graphcoded.exe")) + let packageVersion = try packageVersion(for: bundled) + let status = try awaitBlocking { try await manager.status() } + let processRunning = manager.isDaemonProcessRunning() + let launcherCurrent = manager.launcherIsCurrent() + let currentEndpointGeneration = try? WindowsNamedPipeEndpoint.generation() + let endpointGenerationCurrent = Self.endpointGenerationIsCurrent( + current: currentEndpointGeneration, + installed: installedEndpointGeneration(in: destination)) + let installedCurrent = + installedPackageVersion(in: destination) == packageVersion + && helpersInstalled(in: destination) + && runtimeFilesMatch( + in: destination, + required: bundledRuntimeFiles(in: bundled)) + if installedCurrent, !processRunning { + if status == .running { + // A stale scheduler state must not suppress a restart. + try prepareAndStart(manager, destination: destination) + return .installed + } + if status == .stopped || status == .notInstalled { + try prepareAndStart(manager, destination: destination) + return .installed + } + } + if installedCurrent, status == .running, processRunning, launcherCurrent, + endpointGenerationCurrent + { + return .upToDate + } + + let wasRunning = status == .running || processRunning + if wasRunning { + guard status != .notInstalled else { + throw StartupManagerError.commandFailed( + command: "graphcoded termination", + output: "the daemon process is running without its task") + } + try awaitBlocking { + try await manager.stop() + try await manager.waitForDaemonExit() + try await waitUntilEndpointUnavailable() + try await manager.uninstall() + } + } + + let endpointGeneration = try WindowsNamedPipeEndpoint.generation() + let transaction = try stageAndSwitch( + from: bundled, + to: destination, + version: packageVersion, + endpointGeneration: endpointGeneration) + do { + try awaitBlocking { + try await manager.installAndStart() + } + transaction.commit() + } catch { + transaction.rollback() + if wasRunning { + try? awaitBlocking { + try await manager.installAndStart() + } + } + throw error + } + return .installed + } catch { + return .failed("\(error)") + } + } + + static func bundledHelperDirectory(in bundle: Bundle) -> URL? { + guard let resources = bundle.resourceURL else { return nil } + let bundled = resources.appendingPathComponent("bin", isDirectory: true) + guard helpers.allSatisfy({ + FileManager.default.isExecutableFile(atPath: bundled.appendingPathComponent($0).path) + }) else { + return nil + } + return bundled + } + + static func bundledRuntimeFiles(in directory: URL) -> [URL] { + (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ))? + .filter { $0.pathExtension.caseInsensitiveCompare("dll") == .orderedSame } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + ?? [] + } + + static func helpersInstalled(in directory: URL) -> Bool { + guard helpers.allSatisfy({ + FileManager.default.isReadableFile( + atPath: directory.appendingPathComponent($0).path) + }) else { + return false + } + guard + let manifest = try? String( + contentsOf: directory.appendingPathComponent(runtimeFilesFile), + encoding: .utf8) + else { + return false + } + let required = Set( + manifest.split(whereSeparator: \.isNewline) + .map { $0.lowercased() }) + let installed = Set( + bundledRuntimeFiles(in: directory) + .map { $0.lastPathComponent.lowercased() }) + guard !required.isEmpty, required == installed else { + return false + } + return required.allSatisfy { + FileManager.default.isReadableFile( + atPath: directory.appendingPathComponent($0).path) + } + } + + static func runtimeFilesMatch(in directory: URL, required: [URL]) -> Bool { + Set(bundledRuntimeFiles(in: directory).map { $0.lastPathComponent.lowercased() }) + == Set(required.map { $0.lastPathComponent.lowercased() }) + } + + static func packageVersion(for directory: URL) throws -> String { + if let marker = try? String( + contentsOf: directory.appendingPathComponent(versionFile), encoding: .utf8 + ) { + let value = marker.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + } + let files = helpers.map { directory.appendingPathComponent($0) } + + bundledRuntimeFiles(in: directory) + var material = Data() + for file in files { + material.append(contentsOf: Data(file.lastPathComponent.utf8)) + material.append(0) + material.append(try Data(contentsOf: file)) + material.append(0) + } + return GraphcodeSHA256.hex(material) + } + + static func installedPackageVersion(in directory: URL) -> String? { + try? String( + contentsOf: directory.appendingPathComponent(versionFile), encoding: .utf8 + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func installedEndpointGeneration(in directory: URL) -> String? { + try? String( + contentsOf: directory.appendingPathComponent(endpointGenerationFile), + encoding: .utf8 + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func endpointGenerationIsCurrent(current: String?, installed: String?) -> Bool { + guard let current, let installed else { return false } + return current == installed + } + + private static func prepareAndStart( + _ manager: WindowsStartupManager, + destination: URL + ) throws { + let generation = try WindowsNamedPipeEndpoint.generation() + try Data(generation.utf8).write( + to: destination.appendingPathComponent(endpointGenerationFile), + options: .atomic) + try awaitBlocking { + try await manager.installAndStart() + } + } + + /// Stages and switches a complete versioned package. The returned transaction + /// keeps the old package until the new daemon has started successfully. + static func installBundledFiles( + from bundled: URL, + to destination: URL, + failAfterCopy: Int? = nil + ) throws { + let version = try packageVersion(for: bundled) + let transaction = try stageAndSwitch( + from: bundled, + to: destination, + version: version, + failAfterCopy: failAfterCopy) + transaction.commit() + } + + private static func stageAndSwitch( + from bundled: URL, + to destination: URL, + version: String, + endpointGeneration: String? = nil, + failAfterCopy: Int? = nil + ) throws -> PackageSwitch { + let fileManager = FileManager.default + let runtimeFiles = bundledRuntimeFiles(in: bundled) + let files = helpers.map { bundled.appendingPathComponent($0) } + runtimeFiles + guard !runtimeFiles.isEmpty else { + throw StartupManagerError.missingRuntimeFiles + } + let parent = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + let packageRoot = parent.appendingPathComponent(".graphcode-packages", isDirectory: true) + try fileManager.createDirectory(at: packageRoot, withIntermediateDirectories: true) + let staging = packageRoot.appendingPathComponent( + "\(version)-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: staging, withIntermediateDirectories: true) + defer { + if fileManager.fileExists(atPath: staging.path) { + try? fileManager.removeItem(at: staging) + } + } + + for source in files { + let target = staging.appendingPathComponent(source.lastPathComponent) + try fileManager.copyItem(at: source, to: target) + if let failAfterCopy, files.firstIndex(of: source).map({ $0 + 1 }) == failAfterCopy { + throw StartupManagerError.commandFailed( + command: "copy package", output: "injected mid-package failure") + } + } + try Data(version.utf8).write( + to: staging.appendingPathComponent(versionFile), options: .atomic) + let runtimeManifest = runtimeFiles.map(\.lastPathComponent).joined(separator: "\n") + try Data((runtimeManifest + "\n").utf8).write( + to: staging.appendingPathComponent(runtimeFilesFile), options: .atomic) + if let endpointGeneration { + try Data(endpointGeneration.utf8).write( + to: staging.appendingPathComponent(endpointGenerationFile), options: .atomic) + } + + let backup = parent.appendingPathComponent( + ".graphcode-rollback-\(UUID().uuidString)", isDirectory: true) + if fileManager.fileExists(atPath: destination.path) { + try moveItem(destination, to: backup, replaceExisting: false) + } + do { + try moveItem(staging, to: destination, replaceExisting: false) + } catch { + if fileManager.fileExists(atPath: backup.path) { + try? moveItem(backup, to: destination, replaceExisting: false) + } + throw error + } + return PackageSwitch(destination: destination, backup: backup) + } + + private static func moveItem( + _ source: URL, + to target: URL, + replaceExisting: Bool + ) throws { + var sourcePath = Array(source.path.utf16) + sourcePath.append(0) + var targetPath = Array(target.path.utf16) + targetPath.append(0) + let succeeded = sourcePath.withUnsafeBufferPointer { source in + targetPath.withUnsafeBufferPointer { target in + MoveFileExW( + source.baseAddress, + target.baseAddress, + DWORD( + (replaceExisting ? MOVEFILE_REPLACE_EXISTING : 0) + | MOVEFILE_WRITE_THROUGH)) + } + } + + guard succeeded else { + throw WindowsPipeError.win32(operation: "MoveFileExW", code: GetLastError()) + } + } + + private struct PackageSwitch { + let destination: URL + let backup: URL + + func commit() { + try? FileManager.default.removeItem(at: backup) + } + + func rollback() { + try? FileManager.default.removeItem(at: destination) + if FileManager.default.fileExists(atPath: backup.path) { + try? DaemonBootstrap.moveItem(backup, to: destination, replaceExisting: false) + } + } + } + + private static func waitUntilEndpointUnavailable() async throws { + let endpoint = try WindowsNamedPipeEndpoint.name() + let deadline = Date().addingTimeInterval(10) + while Date() < deadline { + do { + let connection = try WindowsNamedPipeClient.connect( + to: endpoint, timeoutMilliseconds: 100) + try await connection.close() + } catch WindowsPipeError.win32(_, let code) + where code == UInt32(truncatingIfNeeded: ERROR_FILE_NOT_FOUND) + || code == UInt32(truncatingIfNeeded: ERROR_PIPE_NOT_CONNECTED) + { + return + } catch WindowsPipeError.connectionClosed { + try await Task.sleep(for: .milliseconds(50)) + continue + } catch WindowsPipeError.win32(_, let code) + where code == UInt32(truncatingIfNeeded: ERROR_PIPE_BUSY) + || code == UInt32(truncatingIfNeeded: ERROR_SEM_TIMEOUT) + { + try await Task.sleep(for: .milliseconds(50)) + continue + } catch WindowsPipeError.rendezvousSecretInUse { + try await Task.sleep(for: .milliseconds(50)) + continue + } + catch { + throw error + } + } + throw StartupManagerError.commandFailed( + command: "named pipe termination", output: "the daemon endpoint is still available") + } + + private static func awaitBlocking( + _ operation: @escaping () async throws -> Result + ) throws -> Result { + let semaphore = DispatchSemaphore(value: 0) + let box = BlockingResult() + Task { + do { + box.store(.success(try await operation())) + } catch { + box.store(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + return try box.take() + } + + private final class BlockingResult: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func store(_ value: Result) { + lock.lock() + self.value = value + lock.unlock() + } + + func take() throws -> Value { + lock.lock() + defer { lock.unlock() } + guard let value else { fatalError("blocking result was not set") } + return try value.get() + } + } +} + +#endif diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 2e223c48..3c13c6b6 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -246,6 +246,11 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { if !defaultBackend.isSpiked { defaultBackend = oldValue.isSpiked ? oldValue : .claudeCode } } } + /// The model tier used by a new loop when it does not pin one itself. + /// + /// This is deliberately a tier rather than a provider-specific model id so the + /// setting remains useful as backend model names change. + public var defaultModelTier: ModelTier public var claudePermissionMode: ClaudePermissionMode public var copilotPermissions: CopilotPermissions /// Whether a session is told it's part of a graph and how to add loops to it @@ -282,6 +287,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { /// useful during a working session and genuinely thin the moment you relaunch, which /// is a trade worth offering and not worth imposing. public var showsActivityStrip: Bool + /// Whether this installation should receive pre-release updates. + public var betaUpdates: Bool /// Whether graphcode narrates what loops are doing — the summary rail's *producer*. /// @@ -363,6 +370,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { public init( defaultBackend: CLISessionBackendKind = .claudeCode, + defaultModelTier: ModelTier = .standard, codexApprovals: CodexApprovals = .workspace, openCodePermissions: OpenCodePermissions = .auto, claudePermissionMode: ClaudePermissionMode = .auto, @@ -370,6 +378,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { briefsSessionsAboutTheGraph: Bool = true, autoSelectsModel: Bool = false, showsActivityStrip: Bool = false, + betaUpdates: Bool = false, sharesLoops: Bool = true, summarisesLoops: Bool = false, summaryUsesModel: Bool = false, @@ -378,6 +387,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { worktreePolicies: [String: WorktreeHygienePolicy] = [:] ) { self.defaultBackend = defaultBackend.isSpiked ? defaultBackend : .claudeCode + self.defaultModelTier = defaultModelTier self.codexApprovals = codexApprovals self.openCodePermissions = openCodePermissions self.claudePermissionMode = claudePermissionMode @@ -385,6 +395,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.briefsSessionsAboutTheGraph = briefsSessionsAboutTheGraph self.autoSelectsModel = autoSelectsModel self.showsActivityStrip = showsActivityStrip + self.betaUpdates = betaUpdates self.sharesLoops = sharesLoops self.summarisesLoops = summarisesLoops self.summaryUsesModel = summaryUsesModel @@ -402,6 +413,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { try container.decodeIfPresent(CLISessionBackendKind.self, forKey: .defaultBackend) ?? .claudeCode defaultBackend = storedBackend.isSpiked ? storedBackend : .claudeCode + defaultModelTier = + try container.decodeIfPresent(ModelTier.self, forKey: .defaultModelTier) ?? .standard codexApprovals = try container.decodeIfPresent(CodexApprovals.self, forKey: .codexApprovals) ?? .workspace openCodePermissions = @@ -422,6 +435,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { try container.decodeIfPresent(Bool.self, forKey: .autoSelectsModel) ?? false showsActivityStrip = try container.decodeIfPresent(Bool.self, forKey: .showsActivityStrip) ?? false + betaUpdates = + try container.decodeIfPresent(Bool.self, forKey: .betaUpdates) ?? false // Absent takes the new default — on. An explicit `false`, written by anyone who // tried the experiment and switched it off, is preserved; flipping a recorded // choice under someone is what the migration comments above never do. diff --git a/GraphcodeKit/Sources/Domain/LoopSummary.swift b/GraphcodeKit/Sources/Domain/LoopSummary.swift index cd6d3115..d1d6fa64 100644 --- a/GraphcodeKit/Sources/Domain/LoopSummary.swift +++ b/GraphcodeKit/Sources/Domain/LoopSummary.swift @@ -312,6 +312,25 @@ public struct LoopSummary: Codable, Equatable, Sendable { } } + /// How the one number the rail carries is written. + /// + /// Which pass a movement belongs to is `LoopSummary.delta`'s answer, and it is matched on + /// *when* the sample was taken. The values themselves come directly from `metricHistory`, + /// which keeps the summary and sparkline representations consistent. + public enum LoopSummaryDeltas { + /// Short enough for a 188pt line: `1.4k`, `0.62`, `312`. + public static func number(_ value: Double) -> String { + let magnitude = abs(value) + if magnitude >= 1000 { + return String(format: "%.1fk", value / 1000) + } + if magnitude >= 100 || value == value.rounded() { + return String(format: "%.0f", value) + } + return String(format: "%.2f", value) + } + } + /// Where the metric got to over one pass, or nothing — which is most passes. /// /// **Matched by when the samples were taken, not by counting.** The first version keyed diff --git a/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift b/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift index 92f9816c..17ea86af 100644 --- a/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift +++ b/GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift @@ -48,7 +48,13 @@ public struct RemoteProjectLocation: Equatable, Sendable { /// The path string this location travels as — `parse`'s inverse. public var projectPath: String { - "\(Self.scheme)://\(authority)\(remotePath)" + var components = URLComponents() + components.scheme = Self.scheme + components.user = user + components.host = host + components.port = port + components.path = remotePath + return components.string ?? "\(Self.scheme)://\(authority)\(remotePath)" } /// An absolute remote path reduced to the one spelling git will print for it, so two @@ -73,14 +79,16 @@ public struct RemoteProjectLocation: Equatable, Sendable { /// as `-p`, but the authority string carries it for identity and display). public var authority: String { let userPart = user.map { "\($0)@" } ?? "" + let hostPart = host.contains(":") ? "[\(host)]" : host let portPart = port.map { ":\($0)" } ?? "" - return "\(userPart)\(host)\(portPart)" + return "\(userPart)\(hostPart)\(portPart)" } /// What ssh itself is told to connect to — the authority without the port. public var sshDestination: String { let userPart = user.map { "\($0)@" } ?? "" - return "\(userPart)\(host)" + let hostPart = host.contains(":") ? "[\(host)]" : host + return "\(userPart)\(hostPart)" } /// The sidebar title: the repository folder's name, with the host to tell it apart @@ -120,7 +128,7 @@ public struct RemoteProjectLocation: Equatable, Sendable { /// whichever command comes next. If the socket directory is missing ssh just warns and /// dials directly, so this degrades to the old behaviour, never to a failure. public func sshInvocation(remoteCommand: String, interactive: Bool = false) -> [String] { - var invocation = ["/usr/bin/ssh"] + var invocation = [SSHExecutableResolver.executableURL()?.path ?? "ssh"] if interactive { invocation.append("-t") } invocation += [ "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index a142a1ad..b962ea79 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,5 +1,10 @@ import Foundation +public enum GraphStoreCommandResult: Equatable, Sendable { + case applied(graph: LoopGraph) + case rejected(message: String, graph: LoopGraph) +} + /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` /// edges when a node resolves, keeps time-based nodes' sessions alive, and broadcasts /// the updated graph to every connected client. This is the whole of what makes @@ -15,7 +20,7 @@ import Foundation /// /// Lives in `GraphcodeKit`, not `graphcoded/Sources`, even though only the daemon /// instantiates it in production: it has no socket/process-lifecycle coupling of its -/// own (connections are just `[UUID: Int32]` file descriptors handed to it), so it's +/// own (connections are `DaemonConnection` channels handed to it), so it's /// cleanly unit-testable from `graphcodeTests` without spinning up a real daemon /// process or socket. /// @@ -30,8 +35,13 @@ import Foundation /// lifetime, so it needs to be the one minting it. public actor GraphStore { public private(set) var graph: LoopGraph - private var connections: [UUID: Int32] = [:] + private var connections: [UUID: DaemonConnectionChannel] = [:] + private var commandTail: Task? + private var commandTailID: UInt64? + private var nextCommandID: UInt64 = 0 private let onGraphChanged: (@Sendable (LoopGraph) -> Void)? + private let onGraphEvent: (@Sendable (DaemonEvent) -> [UUID: DaemonWireEnvelope])? + private let onConnectionFailure: (@Sendable (UUID) -> Void)? private let onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? private let onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? private let onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? @@ -137,6 +147,7 @@ public actor GraphStore { public private(set) var undeliveredMessages: [(edgeID: UUID, reason: MessageBus.DeliveryFailure)] = [] + private var pendingErrors: [String] = [] /// `onEnsureSession` is how a time-based node's session gets started without this /// actor knowing anything about `zmx` or spawning processes — same injected-closure @@ -146,6 +157,8 @@ public actor GraphStore { public init( graph: LoopGraph = LoopGraph(project: ProjectRef(path: "", name: "Untitled")), onGraphChanged: (@Sendable (LoopGraph) -> Void)? = nil, + onGraphEvent: (@Sendable (DaemonEvent) -> [UUID: DaemonWireEnvelope])? = nil, + onConnectionFailure: (@Sendable (UUID) -> Void)? = nil, onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? = nil, onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? = nil, onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil, @@ -167,6 +180,8 @@ public actor GraphStore { self.graph = graph self.subGraphDepth = subGraphDepth self.onGraphChanged = onGraphChanged + self.onGraphEvent = onGraphEvent + self.onConnectionFailure = onConnectionFailure self.onEnsureSession = onEnsureSession self.onTerminateSession = onTerminateSession self.onEvaluatePredicate = onEvaluatePredicate @@ -217,18 +232,121 @@ public actor GraphStore { // MARK: - Connections - public func addConnection(id: UUID, fileDescriptor: Int32) { - connections[id] = fileDescriptor - send(.graphChanged(graph), to: id) + public func addConnection( + id: UUID, + connection: any DaemonConnection, + mode: DaemonProtocolMode = .v1, + clientID: UUID? = nil, + subscription: DaemonWireSubscription? = nil, + replayStore: DaemonReplayStore = DaemonReplayStore() + ) async { + let channel = DaemonConnectionChannel( + connection: connection, mode: mode, clientID: clientID, + subscription: subscription, replayStore: replayStore) + await addConnection(id: id, channel: channel) } - public func removeConnection(_ id: UUID) { - connections.removeValue(forKey: id) + @discardableResult + public func addConnection(id: UUID, channel: DaemonConnectionChannel) async -> LoopGraph { + connections[id] = channel + await channel.join(projectPath: graph.project.path) + let snapshot = graph + let event = DaemonEvent.graphChanged(snapshot) + do { + try await channel.sendConnectionSnapshot(event) + } catch { + evictConnection(id) + } + return snapshot + } + + #if canImport(Darwin) + /// Compatibility seam for the existing macOS tests and callers. Ownership inside + /// the store is still a `DaemonConnectionChannel`; the descriptor is wrapped at the + /// transport boundary and never retained as an integer here. + public func addConnection(id: UUID, fileDescriptor: Int32) async { + await addConnection( + id: id, + connection: UnixSocketConnection(fileDescriptor: fileDescriptor)) + } + #endif + + @discardableResult + public func removeConnection(_ id: UUID, leaveReplay: Bool = false) async -> LoopGraph? { + guard let channel = connections.removeValue(forKey: id) else { return graph } + let snapshot = graph + if leaveReplay { + await channel.leave(projectPath: graph.project.path) + } + return snapshot } // MARK: - Commands - public func handle(_ command: GraphCommand) async { + public func handle( + _ command: GraphCommand, + broadcastErrors: Bool = true, + v2PayloadLimit: Int? = nil + ) async -> GraphStoreCommandResult { + let previous = commandTail + let commandID = nextCommandID + nextCommandID = nextCommandID == UInt64.max ? 0 : nextCommandID + 1 + let operation = Task { [weak self] in + _ = await previous?.value + guard let self else { + return GraphStoreCommandResult.rejected( + message: "graph store is unavailable", + graph: LoopGraph(project: ProjectRef(path: "", name: "Untitled"))) + } + if let v2PayloadLimit { + let preview = await self.preview(command, broadcastErrors: broadcastErrors) + if case .applied(let projectedGraph) = preview, + !Self.v2GraphChangeFits(projectedGraph, limit: v2PayloadLimit) + { + return .rejected( + message: "resulting graph response exceeds the v2 payload limit", + graph: await self.graph) + } + } + return await self.applyCommand(command, broadcastErrors: broadcastErrors) + } + commandTail = operation + commandTailID = commandID + let result = await operation.value + if commandTailID == commandID { + commandTail = nil + commandTailID = nil + } + return result + } + + /// Runs a command against a side-effect-free copy so a v2 request can be rejected + /// before the real graph, persistence, or broadcast callbacks are touched. + private func preview( + _ command: GraphCommand, + broadcastErrors: Bool + ) async -> GraphStoreCommandResult { + let shadow = GraphStore(graph: graph, subGraphDepth: subGraphDepth) + return await shadow.handle(command, broadcastErrors: broadcastErrors) + } + + private static func v2GraphChangeFits(_ graph: LoopGraph, limit: Int) -> Bool { + guard limit >= 0 else { return false } + let event = DaemonEvent.graphChanged(graph) + let response = DaemonWireEnvelope.response(id: UUID(), event: event) + let broadcast = DaemonWireEnvelope.event(sequence: UInt64.max, event: event) + guard let responseData = try? JSONEncoder().encode(response), + let broadcastData = try? JSONEncoder().encode(broadcast) + else { + return false + } + return responseData.count <= limit && broadcastData.count <= limit + } + + private func applyCommand( + _ command: GraphCommand, + broadcastErrors: Bool = true + ) async -> GraphStoreCommandResult { switch command { case .createNode(var draft): // A child inherits its creator's backend unless one was named: a Copilot loop @@ -241,26 +359,31 @@ public actor GraphStore { draft.backend = graph.nodes[id: creator]?.backend } guard graph.nodes.count < Self.maxNodesPerGraph else { - announceError( - "this graph already has \(graph.nodes.count) loops (limit \(Self.maxNodesPerGraph))") - return + return await reject( + "this graph already has \(graph.nodes.count) loops (limit \(Self.maxNodesPerGraph))", + broadcastErrors: broadcastErrors) } if draft.loopType == .composite && subGraphDepth >= Self.maxSubGraphDepth { - announceError( - "composites are nested \(subGraphDepth) deep (limit \(Self.maxSubGraphDepth))") - return + return await reject( + "composites are nested \(subGraphDepth) deep (limit \(Self.maxSubGraphDepth))", + broadcastErrors: broadcastErrors) + } + guard draft.isValid else { + return await reject( + "node creation refused: draft is invalid", + broadcastErrors: broadcastErrors) } // The experiment's gate: a heartbeat loop created while the toggle is off would // sit silent looking broken, and refusal-with-a-pointer is the export precedent. if let interval = draft.heartbeatIntervalSeconds, interval > 0, onHeartbeatEnabled?() != true { - announceError( + return await reject( "heartbeat loops need the Daemon heartbeat experiment enabled in Settings " - + "(daemonHeartbeatEnabled in ~/.graphcode/settings.json)") - return + + "(daemonHeartbeatEnabled in ~/.graphcode/settings.json)", + broadcastErrors: broadcastErrors) } - guard draft.isValid else { return } + var node = draft.makeNode() // A goal loop is born `.running`, which is right on a project canvas and a lie in a // sub-graph: nothing here has a session until the composite is piloted. Unfixed, @@ -271,7 +394,11 @@ public actor GraphStore { // The draft's id is client-chosen now (see `NodeDraft.id`), so a re-sent command // must not become a second node — or a crash: `IdentifiedArray.append` traps on a // duplicate id, and this protocol is reachable from any client. - guard graph.nodes[id: node.id] == nil else { return } + guard graph.nodes[id: node.id] == nil else { + return await reject( + "node creation refused: a node with that id already exists", + broadcastErrors: broadcastErrors) + } graph.nodes.append(node) linkToCreator(of: node, declaredBy: draft) // A child is handed the report-back route at birth, verbatim. The briefing @@ -305,11 +432,19 @@ public actor GraphStore { // edges of the *same* kind between the same pair still collapse to one. guard from != to, graph.nodes[id: from] != nil, graph.nodes[id: to] != nil, !graph.edges.contains(where: { $0.from == from && $0.to == to && $0.kind == spec.kind }) - else { return } + else { + return await reject( + "edge creation refused: invalid endpoints or duplicate edge", + broadcastErrors: broadcastErrors) + } // A guard that bounds nothing would turn a cycle into an unattended infinite loop // spending tokens forever. Refused outright rather than silently dropped, so the // edge doesn't quietly become a one-shot when the human asked for a loop. - if let cycleGuard = spec.cycleGuard, !cycleGuard.isBounded { return } + if let cycleGuard = spec.cycleGuard, !cycleGuard.isBounded { + return await reject( + "edge creation refused: cycle guards must be bounded", + broadcastErrors: broadcastErrors) + } graph.edges.append(LoopEdge(from: from, to: to, spec: spec)) unblockIfStillIdle(to) @@ -354,7 +489,11 @@ public actor GraphStore { await stopNode(nodeID) case .subGraphCommand(let nodeID, let inner): - await runInSubGraph(nodeID, inner) + if let error = await runInSubGraph( + nodeID, inner, broadcastErrors: broadcastErrors) + { + return await reject(error, broadcastErrors: broadcastErrors) + } case .pilotComposite(let nodeID): await pilotComposite(nodeID) @@ -382,7 +521,11 @@ public actor GraphStore { // before anyone is told what the graph looks like. Cycle re-entries run before // hand-off deliveries because a re-entry *queues* one; nudges last, since an // update's memory record must exist before its session is told to go look. - await drainAndBroadcast() + let errors = await drainAndBroadcast(broadcastErrors: broadcastErrors) + if let error = errors.first { + return .rejected(message: error, graph: graph) + } + return .applied(graph: graph) } // MARK: - Composites @@ -393,7 +536,11 @@ public actor GraphStore { /// rules — rather than a cut-down interpreter. docs/05 is explicit that a composite is /// "the orchestrator running a graph inside a graph"; a second implementation would be /// a second set of bugs about edge firing. - private func runInSubGraph(_ nodeID: UUID, _ command: GraphCommand) async { + private func runInSubGraph( + _ nodeID: UUID, + _ command: GraphCommand, + broadcastErrors: Bool + ) async -> String? { guard let node = graph.nodes[id: nodeID] else { // The id may name a composite further down — a composite inside a composite is the // shape docs/01 describes, and its contents are not in *this* graph's nodes. Ids @@ -402,18 +549,18 @@ public actor GraphStore { // store repeat the search. Without this, `node create --into ` // went nowhere at all. if let owner = graph.nodes.first(where: { $0.subGraph?.containsAtAnyDepth(nodeID) == true }) { - await runInSubGraph(owner.id, .subGraphCommand(nodeID: nodeID, command: command)) - return + return await runInSubGraph( + owner.id, + .subGraphCommand(nodeID: nodeID, command: command), + broadcastErrors: broadcastErrors) } - announceError("no loop \(nodeID) in this graph") - return + return "no loop \(nodeID) in this graph" } // Said out loud rather than returned silently: this is reachable from `node create // --into`, and a command that exits 0 having quietly done nothing is the one answer // worse than refusing. guard node.loopType == .composite, let subGraph = node.subGraph else { - announceError("\(node.title) is not a composite, so it has no sub-graph to run in") - return + return "\(node.title) is not a composite, so it has no sub-graph to run in" } // Built fresh per command rather than cached: the sub-graph lives on the parent @@ -439,9 +586,13 @@ public actor GraphStore { onRefinePlaybook: onRefinePlaybook, onRollbackPlaybook: onRollbackPlaybook, subGraphDepth: subGraphDepth + 1) - await child.handle(command) + let result = await child.handle(command, broadcastErrors: broadcastErrors) graph.nodes[id: nodeID]?.subGraph = await child.graph rollUpComposite(nodeID) + if case .rejected(let message, _) = result { + return message + } + return nil } /// A composite's own state *is* its sub-graph's aggregate — the roll-up docs/05 asks @@ -691,7 +842,9 @@ public actor GraphStore { // what was waiting on exactly that. await drainPendingFollowUps() guard changed else { return } - notifyClients() + let event = DaemonEvent.graphChanged(graph) + let envelopes = onGraphEvent?(event) ?? [:] + await notifyClients(event, envelopes: envelopes) } // MARK: - Renaming @@ -1178,7 +1331,8 @@ public actor GraphStore { // set below — a graph whose nodes have all stopped aggregates to `.idle`. if node.loopType == .composite, let subGraph = node.subGraph { for child in subGraph.nodes where !child.isResolved { - await runInSubGraph(node.id, .stopNode(child.id)) + await runInSubGraph( + node.id, .stopNode(child.id), broadcastErrors: false) } } @@ -1753,9 +1907,16 @@ public actor GraphStore { } private func announceError(_ message: String) { - for id in connections.keys { - send(.errorOccurred(message), to: id) - } + pendingErrors.append(message) + } + + private func reject( + _ message: String, + broadcastErrors: Bool + ) async -> GraphStoreCommandResult { + announceError(message) + _ = await drainAndBroadcast(broadcastErrors: broadcastErrors) + return .rejected(message: message, graph: graph) } private func unblockIfStillIdle(_ nodeID: UUID) { @@ -1947,13 +2108,34 @@ public actor GraphStore { /// The same settle-then-tell sequence `handle` ends with, for the paths that mutate /// outside a command — goal polling resolves nodes and fires edges too, and an edge /// fired from a poll must not wait for the next unrelated command to be delivered. - private func drainAndBroadcast() async { + private func drainAndBroadcast(broadcastErrors: Bool = true) async -> [String] { + let errors = await drainPendingErrors(broadcastErrors: broadcastErrors) await drainPendingMessages() await drainPendingCycleReentries() await drainPendingHandoffDeliveries() await drainPendingNudges() await drainPendingFollowUps() - broadcast() + if errors.isEmpty { + await broadcast() + } + return errors + } + + private func drainPendingErrors(broadcastErrors: Bool) async -> [String] { + guard !pendingErrors.isEmpty else { return [] } + let errors = pendingErrors + pendingErrors.removeAll() + guard broadcastErrors else { return errors } + for message in errors { + for (connectionID, channel) in connections { + do { + try await channel.sendError(message: message) + } catch { + evictConnection(connectionID) + } + } + } + return errors } /// A stalled loop is terminal, and its downstream edges fire as if it failed. Leaving @@ -2073,9 +2255,11 @@ public actor GraphStore { // MARK: - Broadcast - private func broadcast() { + private func broadcast() async { onGraphChanged?(graph) - notifyClients() + let event = DaemonEvent.graphChanged(graph) + let envelopes = onGraphEvent?(event) ?? [:] + await notifyClients(event, envelopes: envelopes) } /// The half of `broadcast` that tells clients, without the half that writes to disk. @@ -2084,21 +2268,47 @@ public actor GraphStore { /// disk: persisting it would be a write every tick for bytes nothing reads back. Every /// other caller wants `broadcast` — a graph change that isn't saved is a graph change /// lost at the next daemon restart. - private func notifyClients() { - for id in connections.keys { - send(.graphChanged(graph), to: id) + private func notifyClients( + _ event: DaemonEvent, + envelopes: [UUID: DaemonWireEnvelope] + ) async { + var fallbackEnvelopes: [UUID: DaemonWireEnvelope] = [:] + for channel in connections.values where envelopes[channel.clientID] == nil { + guard fallbackEnvelopes[channel.clientID] == nil else { continue } + if let envelope = await channel.envelopeForEvent(event) { + fallbackEnvelopes[channel.clientID] = envelope + } + } + for (id, channel) in connections { + await send( + event, + to: id, + envelope: envelopes[channel.clientID] ?? fallbackEnvelopes[channel.clientID]) } } - private func send(_ event: DaemonEvent, to connectionID: UUID) { - guard let fileDescriptor = connections[connectionID] else { return } - guard let data = try? JSONEncoder().encode(event) else { return } - guard (try? FramedMessageIO.writeFrame(data, to: fileDescriptor)) != nil else { + private func send( + _ event: DaemonEvent, + to connectionID: UUID, + envelope: DaemonWireEnvelope? = nil + ) async { + guard let channel = connections[connectionID] else { return } + do { + if let envelope { + try await channel.sendEvent(envelope: envelope) + } else { + try await channel.sendEvent(event) + } + } catch { // The write failed — most likely the client already disconnected. Drop it here // rather than waiting for the read loop to notice, so a dead connection can't // accumulate failed broadcast attempts. - connections.removeValue(forKey: connectionID) - return + evictConnection(connectionID) } } + + private func evictConnection(_ connectionID: UUID) { + guard connections.removeValue(forKey: connectionID) != nil else { return } + onConnectionFailure?(connectionID) + } } diff --git a/GraphcodeKit/Sources/IPC/DaemonConnectionChannel.swift b/GraphcodeKit/Sources/IPC/DaemonConnectionChannel.swift new file mode 100644 index 00000000..8c631cde --- /dev/null +++ b/GraphcodeKit/Sources/IPC/DaemonConnectionChannel.swift @@ -0,0 +1,788 @@ +import Foundation + +public enum DaemonProtocolMode: Equatable, Sendable { + case v1 + case v2(version: Int) +} + +public enum DaemonWireErrorCode: String, Codable, Sendable { + case malformedFrame + case malformedEnvelope + case unsupportedVersion + case expectedHello + case replayUnavailable + case cursorOutsideWindow + case requestFailed + case connectionClosed + case transportFailure +} + +public enum DaemonConnectionChannelError: Error, Equatable, Sendable { + case expectedHello + case unsupportedVersion + case malformedEnvelope + case replayUnavailable + case cursorOutsideWindow + case replayQueueOverflow +} + +/// Replay state is kept separately from a socket. A reconnecting client presents +/// the same `clientID` in hello and can therefore resume a bounded event window. +public final class DaemonReplayStore: @unchecked Sendable { + public let capacity: Int + public let maxClients: Int + public let retention: TimeInterval + private let lock = NSLock() + private var buffers: [UUID: DaemonReplayBuffer] = [:] + private var nextSequences: [UUID: UInt64] = [:] + private var watermarks: [UUID: UInt64] = [:] + private var nonReplayableRanges: [UUID: [ClosedRange]] = [:] + private var lastAccess: [UUID: Date] = [:] + private var subscriptionsByConnection: [UUID: [UUID: DaemonWireSubscription]] = [:] + private var projectPaths: [UUID: Set] = [:] + private var projectPathsByConnection: [UUID: [UUID: Set]] = [:] + private var activeConnections: [UUID: Set] = [:] + + public init( + capacity: Int = 128, + maxClients: Int = 256, + retention: TimeInterval = 3_600 + ) { + self.capacity = max(0, capacity) + self.maxClients = max(0, maxClients) + self.retention = max(0, retention) + } + + public func append(clientID: UUID, event: DaemonEvent) -> DaemonWireEnvelope { + lock.lock() + defer { lock.unlock() } + let now = Date() + purgeExpired(now: now) + _ = ensureClient(clientID) + let envelope = appendLocked(clientID: clientID, event: event) + lastAccess[clientID] = now + return envelope + } + + /// Reserves a sequence for a connection-local snapshot without retaining that + /// snapshot in canonical replay history. This keeps the client's sequence space + /// monotonic while ensuring a repeated project join cannot manufacture history for + /// disconnected logical clients. + public func reserveSequence(clientID: UUID) -> UInt64 { + lock.lock() + defer { lock.unlock() } + let now = Date() + purgeExpired(now: now) + _ = ensureClient(clientID) + let sequence = nextSequences[clientID, default: 1] + nextSequences[clientID] = sequence == UInt64.max ? UInt64.max : sequence + 1 + watermarks[clientID] = sequence + if capacity > 0 { + addNonReplayableSequence(sequence, for: clientID) + } + lastAccess[clientID] = now + return sequence + } + + /// Registers a logical client independently of its current socket. Its bounded + /// history remains eligible for canonical events while every socket for the client + /// is disconnected. + public func register( + clientID: UUID, + connectionID: UUID, + subscription: DaemonWireSubscription? + ) { + lock.lock() + defer { lock.unlock() } + purgeExpired(now: Date()) + _ = ensureClient(clientID) + activeConnections[clientID, default: []].insert(connectionID) + subscriptionsByConnection[clientID, default: [:]][connectionID] = + subscription ?? DaemonWireSubscription() + lastAccess[clientID] = Date() + } + + public func setSubscription( + clientID: UUID, + connectionID: UUID, + subscription: DaemonWireSubscription? + ) { + lock.lock() + defer { lock.unlock() } + guard buffers[clientID] != nil || activeConnections[clientID]?.isEmpty == false else { + return + } + subscriptionsByConnection[clientID, default: [:]][connectionID] = + subscription ?? DaemonWireSubscription() + lastAccess[clientID] = Date() + } + + public func join(clientID: UUID, connectionID: UUID, projectPath: String) { + lock.lock() + defer { lock.unlock() } + guard buffers[clientID] != nil || activeConnections[clientID]?.isEmpty == false else { + return + } + projectPaths[clientID, default: []].insert(projectPath) + projectPathsByConnection[clientID, default: [:]][connectionID, default: []].insert(projectPath) + lastAccess[clientID] = Date() + } + + public func join(clientID: UUID, projectPath: String) { + lock.lock() + defer { lock.unlock() } + guard buffers[clientID] != nil || activeConnections[clientID]?.isEmpty == false else { + return + } + projectPaths[clientID, default: []].insert(projectPath) + lastAccess[clientID] = Date() + } + + public func leave(clientID: UUID, connectionID: UUID, projectPath: String) { + lock.lock() + defer { lock.unlock() } + guard buffers[clientID] != nil || activeConnections[clientID]?.isEmpty == false else { + return + } + var pathsByConnection = projectPathsByConnection[clientID] ?? [:] + var paths = pathsByConnection[connectionID] ?? [] + paths.remove(projectPath) + if paths.isEmpty { + pathsByConnection.removeValue(forKey: connectionID) + } else { + pathsByConnection[connectionID] = paths + } + if pathsByConnection.isEmpty { + projectPathsByConnection.removeValue(forKey: clientID) + } else { + projectPathsByConnection[clientID] = pathsByConnection + } + guard !pathsByConnection.values.contains(where: { $0.contains(projectPath) }) else { + lastAccess[clientID] = Date() + return + } + projectPaths[clientID]?.remove(projectPath) + lastAccess[clientID] = Date() + } + + public func leave(clientID: UUID, projectPath: String) { + lock.lock() + defer { lock.unlock() } + guard buffers[clientID] != nil || activeConnections[clientID]?.isEmpty == false else { + return + } + projectPaths[clientID]?.remove(projectPath) + if let connectionIDs = projectPathsByConnection[clientID]?.keys { + for connectionID in Array(connectionIDs) { + projectPathsByConnection[clientID]?[connectionID]?.remove(projectPath) + if projectPathsByConnection[clientID]?[connectionID]?.isEmpty == true { + projectPathsByConnection[clientID]?.removeValue(forKey: connectionID) + } + } + } + if projectPathsByConnection[clientID]?.isEmpty == true { + projectPathsByConnection.removeValue(forKey: clientID) + } + lastAccess[clientID] = Date() + } + + public func disconnect(clientID: UUID, connectionID: UUID) { + lock.lock() + defer { lock.unlock() } + activeConnections[clientID]?.remove(connectionID) + subscriptionsByConnection[clientID]?.removeValue(forKey: connectionID) + if subscriptionsByConnection[clientID]?.isEmpty == true { + subscriptionsByConnection.removeValue(forKey: clientID) + } + projectPathsByConnection[clientID]?.removeValue(forKey: connectionID) + if projectPathsByConnection[clientID]?.isEmpty == true { + projectPathsByConnection.removeValue(forKey: clientID) + } + if buffers[clientID] == nil, activeConnections[clientID]?.isEmpty != false { + removeClient(clientID) + return + } + lastAccess[clientID] = Date() + } + + /// Appends one canonical graph event to every known logical client attached to the + /// project, including active clients without a replay buffer and clients whose sockets + /// are currently gone. Multiple sockets for one client share the returned envelope. + /// The returned envelopes let live channels write the exact sequence assigned here. + public func append( + event: DaemonEvent, + projectPath: String + ) -> [UUID: DaemonWireEnvelope] { + lock.lock() + defer { lock.unlock() } + let now = Date() + purgeExpired(now: now) + var envelopes: [UUID: DaemonWireEnvelope] = [:] + let clientIDs = Set(buffers.keys).union(activeConnections.keys) + for clientID in clientIDs { + guard projectPaths[clientID]?.contains(projectPath) == true, + isSubscribed(clientID: clientID, projectPath: projectPath) + else { continue } + // A logical client can remain active without a replay buffer while all + // retained slots are occupied. Re-run admission on each canonical event so + // an inactive buffer freed by this append can promote that client without + // resetting its sequence or watermark. + _ = ensureClient(clientID) + envelopes[clientID] = appendLocked(clientID: clientID, event: event) + if activeConnections[clientID]?.isEmpty == false { + lastAccess[clientID] = now + } + } + return envelopes + } + + public func replay(clientID: UUID, after cursor: UInt64) throws -> [DaemonWireEnvelope] { + lock.lock() + defer { lock.unlock() } + let now = Date() + purgeExpired(now: now) + guard let buffer = buffers[clientID] else { + guard let watermark = watermarks[clientID] else { + throw DaemonReplayBuffer.ReplayError.replayUnavailable + } + lastAccess[clientID] = now + if cursor == watermark { return [] } + if cursor > watermark { + throw DaemonReplayBuffer.ReplayError.cursorOutsideWindow + } + throw DaemonReplayBuffer.ReplayError.replayUnavailable + } + lastAccess[clientID] = now + if cursor == watermarks[clientID] { + return [] + } + if buffer.latestSequence == nil { + if cursor > (watermarks[clientID] ?? 0) { + throw DaemonReplayBuffer.ReplayError.cursorOutsideWindow + } + throw DaemonReplayBuffer.ReplayError.replayUnavailable + } + if let latest = buffer.latestSequence, cursor > latest { + guard let watermark = watermarks[clientID], cursor <= watermark, + areNonReplayable( + from: latest == UInt64.max ? UInt64.max : latest + 1, + through: cursor, + for: clientID) + else { + throw DaemonReplayBuffer.ReplayError.cursorOutsideWindow + } + return [] + } + return try buffer.replay( + after: cursor, + skippingRanges: nonReplayableRanges[clientID] ?? []) + } + + public func remove(clientID: UUID) { + lock.lock() + defer { lock.unlock() } + removeClient(clientID) + } + + public func pruneExpired(at now: Date = Date()) { + lock.lock() + defer { lock.unlock() } + purgeExpired(now: now) + } + + public func startCleanup( + every interval: Duration = .seconds(60) + ) -> Task { + Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: interval) + guard !Task.isCancelled else { return } + self?.pruneExpired() + } + } + } + + public var clientCount: Int { + lock.lock() + defer { lock.unlock() } + return buffers.count + } + + private func evictIfNeeded() { + guard buffers.count >= maxClients else { return } + + guard + let oldest = + lastAccess + .filter({ activeConnections[$0.key]?.isEmpty != false }) + .min(by: { $0.value < $1.value })?.key + else { + return + } + removeClient(oldest) + } + + private func purgeExpired(now: Date) { + guard retention.isFinite else { return } + for (clientID, access) in lastAccess where now.timeIntervalSince(access) >= retention { + guard activeConnections[clientID]?.isEmpty != false else { continue } + removeClient(clientID) + } + } + + @discardableResult + private func ensureClient(_ clientID: UUID) -> Bool { + if buffers[clientID] != nil { + ensureSequenceState(clientID) + return true + } + if maxClients == 0 { + ensureSequenceState(clientID) + return false + } + evictIfNeeded() + guard buffers.count < maxClients else { + ensureSequenceState(clientID) + return false + } + buffers[clientID] = DaemonReplayBuffer(capacity: capacity) + ensureSequenceState(clientID) + return true + } + + private func ensureSequenceState(_ clientID: UUID) { + if nextSequences[clientID] == nil { + nextSequences[clientID] = 1 + } + if watermarks[clientID] == nil { + watermarks[clientID] = 0 + } + } + + private func appendLocked(clientID: UUID, event: DaemonEvent) -> DaemonWireEnvelope { + let sequence = nextSequences[clientID, default: 1] + nextSequences[clientID] = sequence == UInt64.max ? UInt64.max : sequence + 1 + watermarks[clientID] = sequence + guard buffers[clientID] != nil else { + return .event(sequence: sequence, event: event) + } + var buffer = buffers[clientID] ?? DaemonReplayBuffer(capacity: capacity) + buffer.append(sequence: sequence, event: event) + buffers[clientID] = buffer + return .event(sequence: sequence, event: event) + } + + private func isSubscribed(clientID: UUID, projectPath: String) -> Bool { + guard let subscriptions = subscriptionsByConnection[clientID], !subscriptions.isEmpty + else { return true } + return subscriptions.values.contains { subscription in + guard let paths = subscription.projectPaths else { return true } + return !paths.isEmpty && paths.contains(projectPath) + } + } + + private func areNonReplayable( + from lowerBound: UInt64, + through upperBound: UInt64, + for clientID: UUID + ) -> Bool { + guard lowerBound <= upperBound else { return true } + return DaemonReplayBuffer.rangesCover( + lowerBound: lowerBound, + upperBound: upperBound, + ranges: nonReplayableRanges[clientID] ?? []) + } + + private func removeClient(_ clientID: UUID) { + buffers.removeValue(forKey: clientID) + nextSequences.removeValue(forKey: clientID) + watermarks.removeValue(forKey: clientID) + lastAccess.removeValue(forKey: clientID) + nonReplayableRanges.removeValue(forKey: clientID) + subscriptionsByConnection.removeValue(forKey: clientID) + projectPaths.removeValue(forKey: clientID) + projectPathsByConnection.removeValue(forKey: clientID) + activeConnections.removeValue(forKey: clientID) + } + + private func addNonReplayableSequence(_ sequence: UInt64, for clientID: UUID) { + var ranges = nonReplayableRanges[clientID] ?? [] + guard !ranges.contains(where: { $0.contains(sequence) }) else { return } + if let index = ranges.firstIndex(where: { + sequence < $0.lowerBound + && sequence != UInt64.max + && sequence + 1 >= $0.lowerBound + }) { + let next = ranges[index] + ranges[index] = sequence...next.upperBound + } else if let index = ranges.firstIndex(where: { + $0.upperBound != UInt64.max + && $0.upperBound + 1 == sequence + }) { + let previous = ranges[index] + ranges[index] = previous.lowerBound...sequence + } else { + ranges.append(sequence...sequence) + ranges.sort { $0.lowerBound < $1.lowerBound } + } + mergeNonReplayableRanges(&ranges) + nonReplayableRanges[clientID] = compactNonReplayableRanges(ranges) + } + + private func mergeNonReplayableRanges(_ ranges: inout [ClosedRange]) { + guard !ranges.isEmpty else { return } + ranges.sort { $0.lowerBound < $1.lowerBound } + var merged: [ClosedRange] = [] + for range in ranges { + guard let last = merged.last else { + merged.append(range) + continue + } + if last.upperBound == UInt64.max + || (range.lowerBound != 0 && last.upperBound + 1 >= range.lowerBound) + { + merged[merged.count - 1] = + last.lowerBound...max(last.upperBound, range.upperBound) + } else { + merged.append(range) + } + } + ranges = merged + } + + private func compactNonReplayableRanges(_ ranges: [ClosedRange]) + -> [ClosedRange] + { + guard capacity > 0 else { return [] } + let maxRanges = max(1, capacity) + guard ranges.count > maxRanges else { return ranges } + // A cursor before the discarded ranges is intentionally no longer resumable: the + // retained ranges still prove every skipped sequence in the bounded replay window. + return Array(ranges.suffix(maxRanges)) + } +} + +/// Serializes writes for one logical client and translates typed daemon events into +/// either the deployed v1 event shape or a v2 envelope. +public actor DaemonConnectionChannel { + public let connection: any DaemonConnection + nonisolated public let mode: DaemonProtocolMode + public let clientID: UUID + public let replayStore: DaemonReplayStore + + public static let maxQueuedLiveEventCount = 256 + public static let maxQueuedLiveEventBytes = 4 * 1024 * 1024 + + private let writeGate: DaemonFrameWriteGate + private var subscription: DaemonWireSubscription? + private var replayInProgress = false + private var queuedLiveEvents: [DaemonWireEnvelope] = [] + private var queuedLiveEventBytes = 0 + private var isClosed = false + + public init( + connection: any DaemonConnection, + mode: DaemonProtocolMode = .v1, + clientID: UUID? = nil, + subscription: DaemonWireSubscription? = nil, + replayStore: DaemonReplayStore = DaemonReplayStore() + ) { + self.connection = connection + self.mode = mode + self.clientID = clientID ?? connection.id + self.subscription = subscription + self.replayStore = replayStore + self.writeGate = daemonFrameWriteGates.gate( + for: connection.id, connection: connection) + if case .v2 = mode { + replayStore.register( + clientID: self.clientID, + connectionID: connection.id, + subscription: subscription) + } + } + + public func setSubscription(_ subscription: DaemonWireSubscription?) { + self.subscription = subscription + if case .v2 = mode { + replayStore.setSubscription( + clientID: clientID, + connectionID: connection.id, + subscription: subscription) + } + } + + public func join(projectPath: String) { + guard case .v2 = mode else { return } + replayStore.join( + clientID: clientID, connectionID: connection.id, projectPath: projectPath) + } + + public func leave(projectPath: String) { + guard case .v2 = mode else { return } + replayStore.leave( + clientID: clientID, connectionID: connection.id, projectPath: projectPath) + } + + public func sendHelloResponse(selectedVersion: Int) async throws { + try await sendJSON(DaemonWireEnvelope.helloResponse(selectedVersion: selectedVersion)) + } + + public func sendEvent(_ event: DaemonEvent) async throws { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + guard isSubscribed(to: event) else { return } + switch mode { + case .v1: + try await sendJSON(event) + case .v2: + let envelope = replayStore.append(clientID: clientID, event: event) + if replayInProgress { + try await enqueueLiveEvent(envelope) + return + } + try await sendJSON(envelope) + } + } + + public func envelopeForEvent(_ event: DaemonEvent) -> DaemonWireEnvelope? { + guard !isClosed, case .v2 = mode, isSubscribed(to: event) else { return nil } + return replayStore.append(clientID: clientID, event: event) + } + + /// Sends a current-graph snapshot to this socket only. Unlike a graph-change event, + /// opening or rejoining a project is not a canonical mutation and must not be + /// replayed to other sockets or retained for a disconnected logical client. + public func sendConnectionSnapshot(_ event: DaemonEvent) async throws { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + guard isSubscribed(to: event) else { return } + switch mode { + case .v1: + try await sendJSON(event) + case .v2: + let sequence = replayStore.reserveSequence(clientID: clientID) + try await sendJSON(DaemonWireEnvelope.event(sequence: sequence, event: event)) + } + } + + /// Writes a sequence already assigned by the canonical replay store. This is used + /// when a graph changed while another socket for the same logical client was away. + public func sendEvent(envelope: DaemonWireEnvelope) async throws { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + guard case .v2 = mode, let event = envelope.event, isSubscribed(to: event) else { return } + if replayInProgress { + try await enqueueLiveEvent(envelope) + return + } + try await sendJSON(envelope) + } + + public func sendResponse(requestID: UUID, event: DaemonEvent) async throws { + switch mode { + case .v1: + try await sendJSON(event) + case .v2: + try await sendJSON(DaemonWireEnvelope.response(id: requestID, event: event)) + } + } + + public func sendSuccess(requestID: UUID) async throws { + guard case .v2 = mode else { return } + try await sendJSON(DaemonWireEnvelope.success(id: requestID)) + } + + public func sendError( + requestID: UUID? = nil, + code: DaemonWireErrorCode = .requestFailed, + message: String + ) async throws { + switch mode { + case .v1: + try await sendJSON(DaemonEvent.errorOccurred(message)) + case .v2: + try await sendJSON( + DaemonWireEnvelope.error( + id: requestID, code: code.rawValue, message: message)) + } + } + + public func replay(after cursor: UInt64) async throws { + guard !isClosed, case .v2 = mode else { + if isClosed { throw FramedMessageIO.IOError.connectionClosed } + return + } + replayInProgress = true + do { + let envelopes = try replayStore.replay(clientID: clientID, after: cursor) + try await writeGate.flush() + for envelope in envelopes { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + guard let event = envelope.event, isSubscribed(to: event) else { continue } + try await sendJSON(envelope) + } + try await flushQueuedLiveEvents() + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + replayInProgress = false + } catch DaemonReplayBuffer.ReplayError.cursorOutsideWindow { + try? await flushQueuedLiveEvents() + replayInProgress = false + queuedLiveEventBytes = 0 + throw DaemonConnectionChannelError.cursorOutsideWindow + } catch DaemonReplayBuffer.ReplayError.replayUnavailable { + try? await flushQueuedLiveEvents() + replayInProgress = false + queuedLiveEventBytes = 0 + throw DaemonConnectionChannelError.replayUnavailable + } catch { + replayInProgress = false + queuedLiveEvents.removeAll() + queuedLiveEventBytes = 0 + throw error + } + } + + public func receiveFrame() async throws -> Data { + try await connection.receiveFrame() + } + + public func close() async throws { + guard !isClosed else { return } + isClosed = true + replayInProgress = false + queuedLiveEvents.removeAll() + queuedLiveEventBytes = 0 + if case .v2 = mode { + replayStore.disconnect(clientID: clientID, connectionID: connection.id) + } + do { + try await connection.close() + } catch { + daemonFrameWriteGates.remove(for: connection.id) + throw error + } + daemonFrameWriteGates.remove(for: connection.id) + } + + private func isSubscribed(to event: DaemonEvent) -> Bool { + guard let paths = subscription?.projectPaths else { return true } + guard !paths.isEmpty else { return false } + switch event { + case .graphChanged(let graph): + return paths.contains(graph.project.path) + case .recentProjectsListed: + return true + case .quickChatsListed, .quickChatChanged, .quickChatDeleted, .quickChatActivity: + return true + case .errorOccurred: + return true + } + } + + private func sendJSON(_ value: T) async throws { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + let data = try JSONEncoder().encode(value) + if case .v2 = mode, data.count > FramedMessageIO.v2MaxPayloadBytes { + throw FramedMessageIO.IOError.payloadTooLarge + } + try await writeGate.send(data) + } + + private func flushQueuedLiveEvents() async throws { + while !queuedLiveEvents.isEmpty { + let events = queuedLiveEvents + queuedLiveEvents.removeAll(keepingCapacity: true) + queuedLiveEventBytes = 0 + for envelope in events { + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + guard let event = envelope.event, isSubscribed(to: event) else { continue } + try await sendJSON(envelope) + } + } + } + + private func enqueueLiveEvent(_ envelope: DaemonWireEnvelope) async throws { + do { + let bytes = try JSONEncoder().encode(envelope).count + guard queuedLiveEvents.count < Self.maxQueuedLiveEventCount, + bytes <= Self.maxQueuedLiveEventBytes, + queuedLiveEventBytes <= Self.maxQueuedLiveEventBytes - bytes + else { + throw DaemonConnectionChannelError.replayQueueOverflow + } + queuedLiveEvents.append(envelope) + queuedLiveEventBytes += bytes + } catch DaemonConnectionChannelError.replayQueueOverflow { + try? await close() + throw DaemonConnectionChannelError.replayQueueOverflow + } + } +} + +/// A task chain keeps complete framed writes non-reentrant. Actor isolation alone +/// is insufficient here: an `await` inside `sendFrame` lets another channel call +/// run before the first header/payload pair has finished. +private actor DaemonFrameWriteGate { + private let connection: any DaemonConnection + private var tail: Task? + private var tailID: UInt64? + private var nextOperationID: UInt64 = 0 + + init(connection: any DaemonConnection) { + self.connection = connection + } + + func send(_ data: Data) async throws { + let previous = tail + let operationID = nextOperationID + nextOperationID = nextOperationID == UInt64.max ? 0 : nextOperationID + 1 + let connection = self.connection + let operation = Task { + if let previous { + try await previous.value + } + try await connection.sendFrame(data) + } + tail = operation + tailID = operationID + do { + try await operation.value + } catch { + if tailID == operationID { + tail = nil + tailID = nil + } + throw error + } + if tailID == operationID { + tail = nil + tailID = nil + } + } + + func flush() async throws { + try await tail?.value + } +} + +private let daemonFrameWriteGates = DaemonFrameWriteGateRegistry() + +private final class DaemonFrameWriteGateRegistry: @unchecked Sendable { + private let lock = NSLock() + private var gates: [UUID: DaemonFrameWriteGate] = [:] + + func gate(for id: UUID, connection: any DaemonConnection) -> DaemonFrameWriteGate { + lock.lock() + defer { lock.unlock() } + if let gate = gates[id] { + return gate + } + let gate = DaemonFrameWriteGate(connection: connection) + gates[id] = gate + return gate + } + + func remove(for id: UUID) { + lock.lock() + defer { lock.unlock() } + gates.removeValue(forKey: id) + } +} diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 13278954..89526bf3 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -37,6 +37,11 @@ public enum DaemonCommand: Codable, Sendable, Equatable { /// Discard a project's saved loops entirely. Irreversible, and separate from /// `forgetProject` precisely because it is. case deleteProjectGraph(path: String) + case listQuickChats + case createQuickChat(title: String, backend: CLISessionBackendKind) + case openQuickChat(id: UUID) + case renameQuickChat(id: UUID, title: String) + case deleteQuickChat(id: UUID) case graphCommand(projectPath: String, command: GraphCommand) } @@ -163,5 +168,9 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { public enum DaemonEvent: Codable, Sendable, Equatable { case recentProjectsListed([ProjectRef]) case graphChanged(LoopGraph) + case quickChatsListed([QuickChat]) + case quickChatChanged(QuickChat) + case quickChatDeleted(UUID) + case quickChatActivity(id: UUID, activity: QuickChatActivity) case errorOccurred(String) } diff --git a/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift b/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift index bcc6cfe7..5ccf6469 100644 --- a/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift +++ b/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift @@ -3,6 +3,9 @@ import Foundation #if canImport(Darwin) import Darwin #endif +#if os(Windows) + import WinSDK +#endif /// A short-lived client for `graphcoded`'s socket — what the `graphcode` CLI talks /// through (docs/03-architecture.md#cli-graphcode). @@ -22,7 +25,25 @@ public struct DaemonSocketClient: Sendable { case timedOut } - private let fileDescriptor: Int32 + public static let ambiguousExitCode: Int32 = 75 + + public static func isAmbiguousConnectionClose(_ error: Error) -> Bool { + if case FramedMessageIO.IOError.connectionClosed = error { + return true + } + #if os(Windows) + if case WindowsPipeError.connectionClosed = error { + return true + } + if case WindowsPipeError.writeOutcomeUnknown = error { + return true + } + #endif + return false + } + + private let connection: any DaemonConnection + private let timeout: TimeInterval /// How long a single read waits before giving up. Generous on purpose: it exists to /// turn "hangs forever with no output" into a diagnosable error, not to bound how long @@ -46,29 +67,57 @@ public struct DaemonSocketClient: Sendable { timeout: TimeInterval = DaemonSocketClient.defaultTimeout, dialAttempts: Int = DaemonSocketClient.defaultDialAttempts ) throws { + let requestedTimeout = max(0, timeout) + self.timeout = requestedTimeout let budget = max(1, dialAttempts) - var descriptor: Int32? + #if os(Windows) + let dialDeadline = Date().addingTimeInterval(requestedTimeout) + #endif + #if canImport(Darwin) + var descriptor: Int32? + #endif for attempt in 0.. 0 else { throw error } + Thread.sleep( + forTimeInterval: min( + remaining, Self.dialBackoff[min(attempt, Self.dialBackoff.count - 1)])) + #else + Thread.sleep( + forTimeInterval: Self.dialBackoff[min(attempt, Self.dialBackoff.count - 1)]) + #endif } } - guard let connected = descriptor else { throw ClientError.daemonNotRunning } - Self.applyReceiveTimeout(timeout, to: connected) - fileDescriptor = connected + #if os(Windows) + throw ClientError.daemonNotRunning + #else + guard let connected = descriptor else { throw ClientError.daemonNotRunning } + connection = UnixSocketConnection(fileDescriptor: connected, readTimeout: timeout) + #endif } /// Wraps an already-connected descriptor. Exists so the timeout and framing behaviour /// can be exercised over a `socketpair` — the public `init` dials the daemon's fixed /// socket path, which a test can't stand in for without disturbing the real daemon. - init(fileDescriptor: Int32, timeout: TimeInterval = DaemonSocketClient.defaultTimeout) { - Self.applyReceiveTimeout(timeout, to: fileDescriptor) - self.fileDescriptor = fileDescriptor - } + #if canImport(Darwin) + init(fileDescriptor: Int32, timeout: TimeInterval = DaemonSocketClient.defaultTimeout) { + self.timeout = max(0, timeout) + connection = UnixSocketConnection(fileDescriptor: fileDescriptor, readTimeout: timeout) + } + #endif /// Only failures that mean "not accepting connections *yet*". A permissions failure or a /// bad path fails identically however long you wait, and retrying those just delays the @@ -78,74 +127,93 @@ public struct DaemonSocketClient: Sendable { case ClientError.daemonNotRunning: return true case ClientError.connectionFailed(let code): - return code == ECONNREFUSED || code == ENOENT || code == EAGAIN || code == EINTR + #if os(Windows) + return code == Int32(truncatingIfNeeded: ERROR_FILE_NOT_FOUND) + || code == Int32(truncatingIfNeeded: ERROR_PIPE_BUSY) + || code == Int32(truncatingIfNeeded: ERROR_SEM_TIMEOUT) + || code == Int32(truncatingIfNeeded: ERROR_PIPE_NOT_CONNECTED) + #else + return code == ECONNREFUSED || code == ENOENT || code == EAGAIN || code == EINTR + #endif default: return false } } - private static func dial() throws -> Int32 { - let path = DaemonSocketPath.url.path - guard FileManager.default.fileExists(atPath: path) else { - throw ClientError.daemonNotRunning + #if os(Windows) + private static func dial(timeout: TimeInterval) throws -> WindowsNamedPipeConnection { + do { + return try WindowsNamedPipeClient.connect( + to: try WindowsNamedPipeEndpoint.name(), + timeoutMilliseconds: timeoutMilliseconds(timeout)) + } catch WindowsPipeError.win32(_, let code) { + throw ClientError.connectionFailed(errno: Int32(bitPattern: code)) + } catch WindowsPipeError.timedOut { + throw ClientError.connectionFailed( + errno: Int32(truncatingIfNeeded: ERROR_SEM_TIMEOUT)) + } catch WindowsPipeError.serverIdentityRejected { + throw ClientError.connectionFailed( + errno: Int32(truncatingIfNeeded: ERROR_ACCESS_DENIED)) + } } - let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) - guard descriptor >= 0 else { throw ClientError.connectionFailed(errno: errno) } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - address.sun_len = UInt8(MemoryLayout.size) - withUnsafeMutablePointer(to: &address.sun_path) { field in - field.withMemoryRebound( - to: CChar.self, capacity: MemoryLayout.size(ofValue: field.pointee) - ) { pointer in - _ = path.withCString { strncpy(pointer, $0, MemoryLayout.size(ofValue: field.pointee) - 1) } - } + private static func timeoutMilliseconds(_ timeout: TimeInterval) -> UInt32 { + guard timeout.isFinite else { return UInt32.max } + return UInt32( + min(Double(UInt32.max), max(0, (timeout * 1_000).rounded(.up)))) } + #else + private static func dial() throws -> Int32 { + let path = DaemonSocketPath.url.path + guard FileManager.default.fileExists(atPath: path) else { + throw ClientError.daemonNotRunning + } - let connected = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(descriptor, $0, socklen_t(MemoryLayout.size)) + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw ClientError.connectionFailed(errno: errno) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + withUnsafeMutablePointer(to: &address.sun_path) { field in + field.withMemoryRebound( + to: CChar.self, capacity: MemoryLayout.size(ofValue: field.pointee) + ) { pointer in + _ = path.withCString { + strncpy(pointer, $0, MemoryLayout.size(ofValue: field.pointee) - 1) + } + } } + + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { + // Captured before `close`, which is itself a syscall and may overwrite `errno` — + // reading it afterwards reported whatever closing did, not why dialling failed. + let code = errno + close(descriptor) + throw ClientError.connectionFailed(errno: code) + } + return descriptor } - guard connected == 0 else { - // Captured before `close`, which is itself a syscall and may overwrite `errno` — - // reading it afterwards reported whatever closing did, not why dialling failed. - let code = errno - close(descriptor) - throw ClientError.connectionFailed(errno: code) - } - return descriptor - } + #endif /// `SO_RCVTIMEO` rather than a watchdog thread: it makes the blocking `read(2)` inside /// `FramedMessageIO` return `EAGAIN` on its own, which keeps this type synchronous and /// needs no cancellation plumbing. Without it a caller waiting on an event the daemon /// never sends — because nothing it sent would cause one — blocks forever with no /// output at all, which is exactly how `status` used to hang. - private static func applyReceiveTimeout(_ timeout: TimeInterval, to descriptor: Int32) { - var interval = timeval( - tv_sec: Int(timeout), - tv_usec: Int32((timeout - timeout.rounded(.down)) * 1_000_000)) - setsockopt( - descriptor, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) - applyNoSignal(to: descriptor) - } - - /// The client half of the daemon's own `SIGPIPE` armour. Writing to a socket the - /// daemon has closed — it restarted, it was stopped mid-exchange — otherwise raises - /// SIGPIPE, and the default action kills the writer: the app, or a `graphcode` - /// invocation that would rather exit 75 and say so. With this the `write(2)` returns - /// `EPIPE`, `FramedMessageIO` throws, and every caller's existing error path runs. - private static func applyNoSignal(to descriptor: Int32) { - var enabled: Int32 = 1 - setsockopt( - descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enabled, socklen_t(MemoryLayout.size)) - } public func send(_ command: DaemonCommand) throws { - try FramedMessageIO.writeFrame(JSONEncoder().encode(command), to: fileDescriptor) + let data = try JSONEncoder().encode(command) + #if canImport(Darwin) + try (connection as! UnixSocketConnection).sendFrameSync(data) + #else + try Self.blocking { try await connection.sendFrame(data) } + #endif } /// Reads events until `isSatisfied` accepts one, the connection closes, or the read @@ -162,14 +230,37 @@ public struct DaemonSocketClient: Sendable { matching isSatisfied: (DaemonEvent) -> Bool, limit: Int = 64 ) throws -> DaemonEvent? { + #if os(Windows) + let responseDeadline = Date().addingTimeInterval(timeout) + #endif for _ in 0..( + _ operation: @escaping () async throws -> Result + ) throws -> Result { + let semaphore = DispatchSemaphore(value: 0) + let box = BlockingResult() + Task { + do { + box.store(.success(try await operation())) + } catch { + box.store(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + return try box.take() + } + + private final class BlockingResult: @unchecked Sendable { + private let lock = NSLock() + private var value: Result? + + func store(_ value: Result) { + lock.lock() + self.value = value + lock.unlock() + } + + func take() throws -> Value { + lock.lock() + defer { lock.unlock() } + guard let value else { fatalError("blocking result was not set") } + return try value.get() + } + } + #endif } diff --git a/GraphcodeKit/Sources/IPC/DaemonSocketPath.swift b/GraphcodeKit/Sources/IPC/DaemonSocketPath.swift index 5587e9ad..5b15556b 100644 --- a/GraphcodeKit/Sources/IPC/DaemonSocketPath.swift +++ b/GraphcodeKit/Sources/IPC/DaemonSocketPath.swift @@ -18,6 +18,23 @@ public enum DaemonSocketPath { url(environment: ProcessInfo.processInfo.environment) } + /// The transport endpoint shared by the daemon and its local clients. + public static var endpoint: DaemonEndpoint { + #if os(Windows) + return .namedPipe((try? WindowsNamedPipeEndpoint.name()) ?? "") + #else + return .unixSocket(url) + #endif + } + + /// The concrete Windows pipe name. Exposed for launchers and tests so they + /// never duplicate the SID/support-directory naming policy. + #if os(Windows) + public static var pipeName: String? { + try? WindowsNamedPipeEndpoint.name() + } + #endif + /// Injected environment, so tests can state an override without mutating the /// process's — the `SupportDirectory.prepare(destination:legacy:)` lesson. static func url(environment: [String: String]) -> URL { diff --git a/GraphcodeKit/Sources/IPC/DaemonTransport.swift b/GraphcodeKit/Sources/IPC/DaemonTransport.swift new file mode 100644 index 00000000..3e0c98a9 --- /dev/null +++ b/GraphcodeKit/Sources/IPC/DaemonTransport.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum DaemonEndpoint: Equatable, Sendable { + case unixSocket(URL) + case namedPipe(String) + case loopbackTCP(host: String, port: UInt16) +} +public protocol DaemonByteStream: Sendable { + func readExactly(_ count: Int) async throws -> Data + func writeAll(_ data: Data) async throws + func close() async throws +} +public protocol DaemonConnection: Sendable { + var id: UUID { get } + var endpoint: DaemonEndpoint { get } + + func receiveFrame() async throws -> Data + func sendFrame(_ data: Data) async throws + func close() async throws +} +public protocol DaemonListener: Sendable { + var endpoint: DaemonEndpoint { get } + + func accept() async throws -> any DaemonConnection + func close() async throws +} diff --git a/GraphcodeKit/Sources/IPC/DaemonWireProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonWireProtocol.swift new file mode 100644 index 00000000..920ee13e --- /dev/null +++ b/GraphcodeKit/Sources/IPC/DaemonWireProtocol.swift @@ -0,0 +1,460 @@ +import Foundation + +public struct DaemonWireError: Codable, Equatable, Sendable { + public var code: String + public var message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +public struct DaemonWireSubscription: Codable, Equatable, Sendable { + /// `nil` means every project visible to the connection. A non-empty list is an + /// explicit allow-list, so a reconnect cannot accidentally resume an unrelated + /// project's events. + public var projectPaths: [String]? + + public init(projectPaths: [String]? = nil) { + self.projectPaths = projectPaths + } +} + +public struct DaemonWireEnvelope: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { + case hello + case request + case response + case event + case error + } + + public var version: Int + public var kind: Kind + public var supportedVersions: [Int]? + public var selectedVersion: Int? + public var clientID: UUID? + public var resumeFrom: UInt64? + public var subscription: DaemonWireSubscription? + public var requestID: UUID? + public var sequence: UInt64? + public var command: DaemonCommand? + public var event: DaemonEvent? + public var error: DaemonWireError? + public var success: Bool? + + public init( + version: Int, + kind: Kind, + supportedVersions: [Int]? = nil, + selectedVersion: Int? = nil, + clientID: UUID? = nil, + resumeFrom: UInt64? = nil, + subscription: DaemonWireSubscription? = nil, + requestID: UUID? = nil, + sequence: UInt64? = nil, + command: DaemonCommand? = nil, + event: DaemonEvent? = nil, + error: DaemonWireError? = nil, + success: Bool? = nil + ) { + self.version = version + self.kind = kind + self.supportedVersions = supportedVersions + self.selectedVersion = selectedVersion + self.clientID = clientID + self.resumeFrom = resumeFrom + self.subscription = subscription + self.requestID = requestID + self.sequence = sequence + self.command = command + self.event = event + self.error = error + self.success = success + } + + public static func hello( + supportedVersions: [Int], + clientID: UUID? = nil, + resumeFrom: UInt64? = nil, + subscription: DaemonWireSubscription? = nil + ) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .hello, + supportedVersions: supportedVersions, + clientID: clientID, + resumeFrom: resumeFrom, + subscription: subscription) + } + + public static func helloResponse(selectedVersion: Int) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .hello, + supportedVersions: DaemonWireProtocol.supportedVersions, + selectedVersion: selectedVersion) + } + + public static func request(id: UUID, command: DaemonCommand) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .request, + requestID: id, + command: command) + } + + public static func response(id: UUID, event: DaemonEvent) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .response, + requestID: id, + event: event) + } + + public static func success(id: UUID) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .response, + requestID: id, + success: true) + } + + public static func event(sequence: UInt64, event: DaemonEvent) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .event, + sequence: sequence, + event: event) + } + + public static func error(id: UUID?, code: String, message: String) -> Self { + Self( + version: DaemonWireProtocol.currentVersion, + kind: .error, + requestID: id, + error: DaemonWireError(code: code, message: message)) + } + + @discardableResult + public func validated() throws -> Self { + guard version == DaemonWireProtocol.currentVersion else { + throw ValidationError.unsupportedVersion(version) + } + + switch kind { + case .hello: + guard let supportedVersions, !supportedVersions.isEmpty else { + throw ValidationError.missingField("supportedVersions") + } + guard Set(supportedVersions).count == supportedVersions.count else { + throw ValidationError.invalidField("supportedVersions") + } + if let selectedVersion { + guard supportedVersions.contains(selectedVersion), + DaemonWireProtocol.supportedVersions.contains(selectedVersion) + else { + throw ValidationError.invalidField("selectedVersion") + } + } + if let subscription, let paths = subscription.projectPaths { + guard paths.allSatisfy({ !$0.isEmpty }) else { + throw ValidationError.invalidField("subscription") + } + } + guard requestID == nil, sequence == nil, command == nil, event == nil, error == nil, + success == nil + else { + throw ValidationError.unexpectedField + } + case .request: + guard requestID != nil else { throw ValidationError.missingField("requestID") } + guard command != nil else { throw ValidationError.missingField("command") } + guard supportedVersions == nil, selectedVersion == nil, clientID == nil, resumeFrom == nil, + subscription == nil, sequence == nil, event == nil, error == nil, success == nil + else { + throw ValidationError.unexpectedField + } + case .response: + guard requestID != nil else { throw ValidationError.missingField("requestID") } + guard event != nil || success == true else { throw ValidationError.missingField("event") } + guard success != false else { throw ValidationError.invalidField("success") } + guard supportedVersions == nil, selectedVersion == nil, clientID == nil, resumeFrom == nil, + subscription == nil, sequence == nil, command == nil, error == nil + else { + throw ValidationError.unexpectedField + } + case .event: + guard sequence != nil else { throw ValidationError.missingField("sequence") } + guard event != nil else { throw ValidationError.missingField("event") } + guard supportedVersions == nil, selectedVersion == nil, clientID == nil, resumeFrom == nil, + subscription == nil, requestID == nil, command == nil, error == nil, success == nil + else { + throw ValidationError.unexpectedField + } + case .error: + guard error != nil else { throw ValidationError.missingField("error") } + guard let error, !error.code.isEmpty, !error.message.isEmpty else { + throw ValidationError.invalidField("error") + } + guard supportedVersions == nil, selectedVersion == nil, clientID == nil, resumeFrom == nil, + subscription == nil, sequence == nil, command == nil, event == nil, success == nil + else { + throw ValidationError.unexpectedField + } + } + + return self + } + + public enum ValidationError: Error, Equatable { + case unsupportedVersion(Int) + case missingField(String) + case invalidField(String) + case payloadTooLarge + case unexpectedField + } +} +public enum DaemonClientFrame: Equatable, Sendable { + case v1(DaemonCommand) + case v2(DaemonWireEnvelope) +} +public enum DaemonWireProtocol { + public static let supportedVersions = [1, 2] + public static let currentVersion = 2 + + public static func decodeClientFrame(_ data: Data) throws -> DaemonClientFrame { + let object = try JSONSerialization.jsonObject(with: data) + if isV2ShapedFrame(object) { + guard data.count <= FramedMessageIO.v2MaxPayloadBytes else { + throw DaemonWireEnvelope.ValidationError.payloadTooLarge + } + let envelope = try JSONDecoder().decode(DaemonWireEnvelope.self, from: data) + return .v2(try envelope.validated()) + } + return .v1(try JSONDecoder().decode(DaemonCommand.self, from: data)) + } + + public static func isV2ShapedFrame(_ data: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: data) else { return false } + return isV2ShapedFrame(object) + } + + public static func initialErrorFrame(for data: Data, message: String) throws -> Data { + if isV2ShapedFrame(data) { + return try JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, + code: initialV2ErrorCode(for: data), + message: message)) + } + return try JSONEncoder().encode(DaemonEvent.errorOccurred(message)) + } + + /// Extracts a request correlation ID before full envelope validation. This is + /// intentionally conservative: only a version-2 request-shaped JSON object + /// with a valid UUID is eligible for correlation. + public static func requestIDIfPresent(in data: Data) -> UUID? { + guard let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any], + let version = dictionary["version"] as? Int, + version == currentVersion, + dictionary["kind"] as? String == DaemonWireEnvelope.Kind.request.rawValue, + let rawID = dictionary["requestID"] as? String + else { + return nil + } + return UUID(uuidString: rawID) + } + + public static func negotiatedVersion(for hello: DaemonWireEnvelope) throws -> Int { + let validated = try hello.validated() + guard validated.kind == .hello, let offered = validated.supportedVersions else { + throw NegotiationError.expectedHello + } + guard let selected = Set(offered).intersection(supportedVersions).max() else { + throw NegotiationError.noSupportedVersion + } + return selected + } + + public static func negotiatedHelloResponse(for hello: DaemonWireEnvelope) throws + -> DaemonWireEnvelope + { + DaemonWireEnvelope.helloResponse( + selectedVersion: try negotiatedVersion(for: hello)) + } + + public enum NegotiationError: Error, Equatable { + case expectedHello + case noSupportedVersion + } + + private static func isV2ShapedFrame(_ object: Any) -> Bool { + guard let dictionary = object as? [String: Any] else { return false } + return dictionary["version"] != nil || dictionary["kind"] != nil + } + + private static func initialV2ErrorCode(for data: Data) -> String { + guard let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any], + let rawVersion = dictionary["version"], + let version = rawVersion as? Int + else { + return DaemonWireErrorCode.malformedEnvelope.rawValue + } + return version == currentVersion + ? DaemonWireErrorCode.malformedEnvelope.rawValue + : DaemonWireErrorCode.unsupportedVersion.rawValue + } +} + +/// A bounded, monotonic event history used to replay a v2 subscription after a +/// reconnect. The daemon keeps this per logical client rather than per socket, so +/// reconnecting does not reset the sequence seen by the client. +public struct DaemonReplayBuffer: Equatable, Sendable { + public enum ReplayError: Error, Equatable { + case invalidCapacity + case nonMonotonicSequence + case replayUnavailable + case cursorOutsideWindow + } + + public let capacity: Int + private var entries: [DaemonWireEnvelope] = [] + + public init(capacity: Int = 128) { + self.capacity = max(0, capacity) + } + + public var firstSequence: UInt64? { entries.first?.sequence } + public var latestSequence: UInt64? { entries.last?.sequence } + + public mutating func append(sequence: UInt64, event: DaemonEvent) { + guard capacity > 0 else { return } + if let latest = latestSequence, sequence <= latest { + return + } + entries.append(.event(sequence: sequence, event: event)) + if entries.count > capacity { + entries.removeFirst(entries.count - capacity) + } + } + + public func replay( + after cursor: UInt64, + skipping nonReplayableSequences: Set = [] + ) throws -> [DaemonWireEnvelope] { + try replay( + after: cursor, + skippingRanges: Self.sequenceRanges(from: nonReplayableSequences)) + } + + public func replay( + after cursor: UInt64, + skippingRanges: [ClosedRange] + ) throws -> [DaemonWireEnvelope] { + guard capacity > 0 else { + throw ReplayError.replayUnavailable + } + guard let first = firstSequence, let latest = latestSequence else { + throw ReplayError.replayUnavailable + } + if cursor == latest { return [] } + if cursor > latest { throw ReplayError.cursorOutsideWindow } + if cursor < first { + let missingCount = first - (cursor + 1) + if missingCount > 0 { + guard Self.rangesCover( + lowerBound: cursor + 1, + upperBound: first - 1, + ranges: skippingRanges) + else { + throw ReplayError.cursorOutsideWindow + } + } + } + return entries.filter { ($0.sequence ?? 0) > cursor } + } + + public static func sequenceRanges(from sequences: Set) -> [ClosedRange] { + let sorted = sequences.sorted() + guard var start = sorted.first else { return [] } + var end = start + var ranges: [ClosedRange] = [] + for sequence in sorted.dropFirst() { + if end != UInt64.max, sequence == end + 1 { + end = sequence + } else { + ranges.append(start...end) + start = sequence + end = sequence + } + } + ranges.append(start...end) + return ranges + } + + public static func rangesCover( + lowerBound: UInt64, + upperBound: UInt64, + ranges: [ClosedRange] + ) -> Bool { + guard lowerBound <= upperBound else { return true } + var next = lowerBound + for range in ranges.sorted(by: { $0.lowerBound < $1.lowerBound }) { + guard range.upperBound >= next else { continue } + guard range.lowerBound <= next else { return false } + if range.upperBound >= upperBound { return true } + guard range.upperBound < UInt64.max else { return true } + next = range.upperBound + 1 + } + return false + } +} +public enum DaemonFrameHeader { + public static let byteCount = 4 + /// The v2 envelope cap. Legacy frames use the larger bounded reader ceiling below. + public static let maxPayloadBytes: UInt32 = 1_048_576 + /// Allocation ceiling for deployed v1 frames whose payloads exceed the v2 cap. + public static let legacySafetyCeilingBytes: UInt32 = 2 * 1_048_576 + /// The four-byte header itself remains a full UInt32 length field. + public static let maxUInt32PayloadBytes = UInt32.max + + public static func encodeLength( + _ length: Int, maxPayloadBytes: UInt32? = nil + ) throws -> Data { + guard length >= 0, let value = UInt32(exactly: length), + maxPayloadBytes.map({ value <= $0 }) ?? true + else { + throw HeaderError.payloadTooLarge + } + return Data([ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ]) + } + + public static func decodeLength( + _ bytes: [UInt8], maxPayloadBytes: UInt32? = nil + ) throws -> Int { + guard bytes.count == byteCount else { throw HeaderError.invalidHeader } + let value = + (UInt32(bytes[0]) << 24) + | (UInt32(bytes[1]) << 16) + | (UInt32(bytes[2]) << 8) + | UInt32(bytes[3]) + guard maxPayloadBytes.map({ value <= $0 }) ?? true else { + throw HeaderError.payloadTooLarge + } + return Int(value) + } + + public enum HeaderError: Error, Equatable { + case invalidHeader + case payloadTooLarge + } +} diff --git a/GraphcodeKit/Sources/IPC/FramedMessageIO.swift b/GraphcodeKit/Sources/IPC/FramedMessageIO.swift index e33f9ebc..210d4389 100644 --- a/GraphcodeKit/Sources/IPC/FramedMessageIO.swift +++ b/GraphcodeKit/Sources/IPC/FramedMessageIO.swift @@ -2,9 +2,12 @@ import Foundation #if canImport(Darwin) import Darwin +#elseif canImport(Glibc) + import Glibc #endif -/// Length-prefixed framing over a raw socket file descriptor — a 4-byte big-endian +/// Length-prefixed framing over a raw socket file descriptor or a bounded byte stream — +/// a 4-byte big-endian /// length header followed by that many bytes of JSON. Shared by `graphcoded`'s /// connection handlers and the app's `OrchestratorClient` so both sides always agree /// on where one message ends and the next begins. @@ -24,90 +27,170 @@ import Foundation /// graphcoded may be older than the client that sent it" was really reporting when a new /// workspace opened (0.1.46-beta1): its three launch commands wait together on the /// just-bootstrapped daemon and are released at the same instant. +/// +/// The stream-based overloads below hold the same invariant a different way: every +/// `DaemonConnection` writes through its own serial queue, so the lock table applies +/// only to the descriptor-based path. public enum FramedMessageIO { + public static let v2MaxPayloadBytes = Int(DaemonFrameHeader.maxPayloadBytes) + public static let legacyMaxPayloadBytes = Int(DaemonFrameHeader.legacySafetyCeilingBytes) + public static let maxPayloadBytes = v2MaxPayloadBytes + public enum IOError: Error, Equatable { case connectionClosed case readFailed(errno: Int32) case writeFailed(errno: Int32) + case invalidHeader + case payloadTooLarge } - public static func writeFrame(_ data: Data, to fileDescriptor: Int32) throws { - let lock = writeLocks.lock(forDescriptor: fileDescriptor) - lock.lock() - defer { lock.unlock() } + #if canImport(Darwin) || canImport(Glibc) + public static func writeFrame( + _ data: Data, + to fileDescriptor: Int32, + maxPayloadBytes: Int = legacyMaxPayloadBytes + ) throws { + let lock = writeLocks.lock(forDescriptor: fileDescriptor) + lock.lock() + defer { lock.unlock() } + + guard let limit = UInt32(exactly: maxPayloadBytes) else { + throw IOError.payloadTooLarge + } + let header: Data + do { + header = try DaemonFrameHeader.encodeLength( + data.count, maxPayloadBytes: limit) + } catch { + throw IOError.payloadTooLarge + } + try writeAll(header, to: fileDescriptor) + try writeAll(data, to: fileDescriptor) + } + + public static func readFrame( + from fileDescriptor: Int32, + maxPayloadBytes: Int = legacyMaxPayloadBytes + ) throws -> Data { + guard let limit = UInt32(exactly: maxPayloadBytes) else { + throw IOError.payloadTooLarge + } + let header = try readExactly(DaemonFrameHeader.byteCount, from: fileDescriptor) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: limit) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw IOError.invalidHeader + } catch { + throw IOError.payloadTooLarge + } + return length == 0 ? Data() : try readExactly(length, from: fileDescriptor) + } + #endif - let length = UInt32(data.count) - let header: [UInt8] = [ - UInt8((length >> 24) & 0xff), - UInt8((length >> 16) & 0xff), - UInt8((length >> 8) & 0xff), - UInt8(length & 0xff), - ] - try writeAll(Data(header), to: fileDescriptor) - try writeAll(data, to: fileDescriptor) + /// The transport-independent path used by named pipes, TCP, and test streams. + /// Exact operations make partial reads and writes an adapter concern rather than + /// allowing a short operation to be mistaken for a complete frame. + public static func writeFrame( + _ data: Data, + to stream: any DaemonByteStream, + maxPayloadBytes: Int = legacyMaxPayloadBytes + ) async throws { + guard let limit = UInt32(exactly: maxPayloadBytes) else { + throw IOError.payloadTooLarge + } + let header: Data + do { + header = try DaemonFrameHeader.encodeLength( + data.count, maxPayloadBytes: limit) + } catch { + throw IOError.payloadTooLarge + } + try await stream.writeAll(header) + if !data.isEmpty { + try await stream.writeAll(data) + } } - public static func readFrame(from fileDescriptor: Int32) throws -> Data { - let header = try readExactly(4, from: fileDescriptor) - let length = - (UInt32(header[header.startIndex]) << 24) - | (UInt32(header[header.startIndex + 1]) << 16) - | (UInt32(header[header.startIndex + 2]) << 8) - | UInt32(header[header.startIndex + 3]) - return try readExactly(Int(length), from: fileDescriptor) + public static func readFrame( + from stream: any DaemonByteStream, + maxPayloadBytes: Int = legacyMaxPayloadBytes + ) async throws -> Data { + guard let limit = UInt32(exactly: maxPayloadBytes) else { + throw IOError.payloadTooLarge + } + let header = try await stream.readExactly(DaemonFrameHeader.byteCount) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: limit) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw IOError.invalidHeader + } catch { + throw IOError.payloadTooLarge + } + return length == 0 ? Data() : try await stream.readExactly(length) } - /// One lock per descriptor rather than one for the whole process: a write blocks while - /// its socket buffer is full, and the daemon broadcasts to every connected client — a - /// single lock would let one wedged client stall the writes to all the others. - /// - /// Locks are kept rather than reclaimed on close. Descriptor numbers are small and the - /// kernel reuses them, so the table stays about as large as the peak connection count, - /// and a reused number simply gets the same lock back — correct either way, where - /// discarding one a concurrent writer still holds would not be. - private static let writeLocks = DescriptorLocks() + #if canImport(Darwin) || canImport(Glibc) + /// One lock per descriptor rather than one for the whole process: a write blocks while + /// its socket buffer is full, and the daemon broadcasts to every connected client — a + /// single lock would let one wedged client stall the writes to all the others. + /// + /// Locks are kept rather than reclaimed on close. Descriptor numbers are small and the + /// kernel reuses them, so the table stays about as large as the peak connection count, + /// and a reused number simply gets the same lock back — correct either way, where + /// discarding one a concurrent writer still holds would not be. + private static let writeLocks = DescriptorLocks() - private final class DescriptorLocks: @unchecked Sendable { - private var locks: [Int32: NSLock] = [:] - private let tableLock = NSLock() + private final class DescriptorLocks: @unchecked Sendable { + private var locks: [Int32: NSLock] = [:] + private let tableLock = NSLock() - func lock(forDescriptor descriptor: Int32) -> NSLock { - tableLock.lock() - defer { tableLock.unlock() } - if let existing = locks[descriptor] { return existing } - let created = NSLock() - locks[descriptor] = created - return created + func lock(forDescriptor descriptor: Int32) -> NSLock { + tableLock.lock() + defer { tableLock.unlock() } + if let existing = locks[descriptor] { return existing } + let created = NSLock() + locks[descriptor] = created + return created + } } - } - private static func writeAll(_ data: Data, to fileDescriptor: Int32) throws { - try data.withUnsafeBytes { (rawBuffer: UnsafeRawBufferPointer) in - var remaining = rawBuffer.count - var pointer = rawBuffer.baseAddress! - while remaining > 0 { - let written = write(fileDescriptor, pointer, remaining) - if written <= 0 { - throw IOError.writeFailed(errno: errno) + private static func writeAll(_ data: Data, to fileDescriptor: Int32) throws { + try data.withUnsafeBytes { (rawBuffer: UnsafeRawBufferPointer) in + guard let baseAddress = rawBuffer.baseAddress else { return } + var remaining = rawBuffer.count + var pointer = baseAddress + while remaining > 0 { + let written = write(fileDescriptor, pointer, remaining) + if written < 0, errno == EINTR { continue } + if written <= 0 { + throw IOError.writeFailed(errno: errno) + } + remaining -= written + pointer = pointer.advanced(by: written) } - remaining -= written - pointer = pointer.advanced(by: written) } } - } - private static func readExactly(_ count: Int, from fileDescriptor: Int32) throws -> Data { - guard count > 0 else { return Data() } - var buffer = [UInt8](repeating: 0, count: count) - var totalRead = 0 - while totalRead < count { - let bytesRead = buffer.withUnsafeMutableBytes { rawBuffer -> Int in - read(fileDescriptor, rawBuffer.baseAddress!.advanced(by: totalRead), count - totalRead) + private static func readExactly(_ count: Int, from fileDescriptor: Int32) throws -> Data { + guard count > 0 else { return Data() } + var buffer = [UInt8](repeating: 0, count: count) + var totalRead = 0 + while totalRead < count { + let bytesRead = buffer.withUnsafeMutableBytes { rawBuffer -> Int in + read( + fileDescriptor, rawBuffer.baseAddress!.advanced(by: totalRead), + count - totalRead) + } + if bytesRead == 0 { throw IOError.connectionClosed } + if bytesRead < 0, errno == EINTR { continue } + if bytesRead < 0 { throw IOError.readFailed(errno: errno) } + totalRead += bytesRead } - if bytesRead == 0 { throw IOError.connectionClosed } - if bytesRead < 0 { throw IOError.readFailed(errno: errno) } - totalRead += bytesRead + return Data(buffer) } - return Data(buffer) - } + #endif } diff --git a/GraphcodeKit/Sources/IPC/UnixDaemonTransport.swift b/GraphcodeKit/Sources/IPC/UnixDaemonTransport.swift new file mode 100644 index 00000000..a1289520 --- /dev/null +++ b/GraphcodeKit/Sources/IPC/UnixDaemonTransport.swift @@ -0,0 +1,393 @@ +import Foundation + +#if canImport(Darwin) + import Darwin + + /// A small async adapter around a Unix descriptor. Blocking syscalls are moved off + /// Swift's cooperative executor; socket receive/send timeouts bound stalled peers. + public final class UnixSocketByteStream: @unchecked Sendable, DaemonByteStream { + private let fileDescriptor: Int32 + private let closeOnClose: Bool + private let lock = NSLock() + private var isClosed = false + + public init( + fileDescriptor: Int32, + readTimeout: TimeInterval? = nil, + writeTimeout: TimeInterval? = nil, + closeOnClose: Bool = true + ) { + self.fileDescriptor = fileDescriptor + self.closeOnClose = closeOnClose + Self.applyTimeout(readTimeout, to: fileDescriptor, option: SO_RCVTIMEO) + Self.applyTimeout(writeTimeout, to: fileDescriptor, option: SO_SNDTIMEO) + var noSignal = 1 + _ = setsockopt( + fileDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, + socklen_t(MemoryLayout.size)) + } + + public func readExactly(_ count: Int) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + continuation.resume(returning: try Self.read(count, from: self.fileDescriptor)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + public func writeAll(_ data: Data) async throws { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + try Self.write(data, to: self.fileDescriptor) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + public func close() async throws { + closeSync() + } + + public func setReadTimeout(_ timeout: TimeInterval?) { + Self.applyTimeout(timeout, to: fileDescriptor, option: SO_RCVTIMEO) + } + + public func closeSync() { + lock.lock() + let shouldClose = !isClosed + isClosed = true + lock.unlock() + if shouldClose, closeOnClose { + Darwin.close(fileDescriptor) + } + } + + fileprivate func readExactlySync(_ count: Int) throws -> Data { + try Self.read(count, from: fileDescriptor) + } + + fileprivate func readExactlySync(_ count: Int, by deadline: Date) throws -> Data { + try Self.read(count, from: fileDescriptor, by: deadline) + } + + fileprivate func writeFrameSync(_ data: Data) throws { + try FramedMessageIO.writeFrame(data, to: fileDescriptor) + } + + private static func applyTimeout( + _ timeout: TimeInterval?, to fileDescriptor: Int32, option: Int32 + ) { + var interval = timeval(tv_sec: 0, tv_usec: 0) + if let timeout, timeout.isFinite, timeout >= 0 { + interval = timeval( + tv_sec: Int(timeout), + tv_usec: Int32((timeout - timeout.rounded(.down)) * 1_000_000)) + } + _ = setsockopt( + fileDescriptor, SOL_SOCKET, option, &interval, + socklen_t(MemoryLayout.size)) + } + + private static func write(_ data: Data, to fileDescriptor: Int32) throws { + try data.withUnsafeBytes { (rawBuffer: UnsafeRawBufferPointer) in + guard let baseAddress = rawBuffer.baseAddress else { return } + var remaining = rawBuffer.count + var pointer = baseAddress + while remaining > 0 { + let result = Darwin.write(fileDescriptor, pointer, remaining) + if result < 0, errno == EINTR { continue } + if result <= 0 { + throw FramedMessageIO.IOError.writeFailed(errno: errno) + } + remaining -= result + pointer = pointer.advanced(by: result) + } + } + } + + private static func read(_ count: Int, from fileDescriptor: Int32) throws -> Data { + try read(count, from: fileDescriptor, by: nil) + } + + private static func read( + _ count: Int, + from fileDescriptor: Int32, + by deadline: Date? + ) throws -> Data { + guard count > 0 else { return Data() } + var buffer = [UInt8](repeating: 0, count: count) + var total = 0 + while total < count { + if let deadline { + try waitUntilReadable(fileDescriptor, by: deadline) + } + let result = buffer.withUnsafeMutableBytes { rawBuffer in + Darwin.read( + fileDescriptor, rawBuffer.baseAddress!.advanced(by: total), count - total) + } + if result == 0 { throw FramedMessageIO.IOError.connectionClosed } + if result < 0, errno == EINTR { continue } + if result < 0 { + throw FramedMessageIO.IOError.readFailed(errno: errno) + } + total += result + } + return Data(buffer) + } + + private static func waitUntilReadable( + _ fileDescriptor: Int32, + by deadline: Date + ) throws { + while true { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw FramedMessageIO.IOError.readFailed(errno: EAGAIN) + } + let milliseconds = min( + Double(Int32.max), + max(1, (remaining * 1_000).rounded(.up))) + var descriptor = pollfd( + fd: fileDescriptor, + events: Int16(POLLIN), + revents: 0) + let result = poll(&descriptor, 1, Int32(milliseconds)) + if result > 0 { return } + if result == 0 { + throw FramedMessageIO.IOError.readFailed(errno: EAGAIN) + } + if errno == EINTR { continue } + throw FramedMessageIO.IOError.readFailed(errno: errno) + } + } + } + + /// Unix-domain socket implementation of the portable connection contract. + public final class UnixSocketConnection: @unchecked Sendable, DaemonConnection { + public let id: UUID + public let endpoint: DaemonEndpoint + private let stream: UnixSocketByteStream + private let acceptsWrites: Bool + private let writeQueue = DispatchQueue( + label: "com.graphcode.unix-socket-frame-writes") + private let stateLock = NSLock() + private var isClosed = false + + public init( + id: UUID = UUID(), + fileDescriptor: Int32, + endpoint: DaemonEndpoint = .unixSocket(URL(fileURLWithPath: "")), + readTimeout: TimeInterval? = nil, + writeTimeout: TimeInterval? = nil + ) { + self.id = id + self.endpoint = endpoint + // Compatibility callers use -1 as an intentionally inert descriptor in tests. + self.acceptsWrites = fileDescriptor >= 0 + self.stream = UnixSocketByteStream( + fileDescriptor: fileDescriptor, + readTimeout: readTimeout, + writeTimeout: writeTimeout) + } + + public func receiveFrame() async throws -> Data { + try await FramedMessageIO.readFrame(from: stream) + } + + /// Reads the next frame without timing out an idle connection, then applies one + /// cumulative deadline after its first byte arrives. This keeps accepted clients + /// parked indefinitely between frames while ensuring a peer that starts a header or + /// payload cannot hold a daemon task forever. + public func receiveFrameWithPostHandshakeDeadline( + _ timeout: TimeInterval = 5 + ) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + continuation.resume( + returning: try self.receiveFrameWithPostHandshakeDeadlineSync(timeout)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + public func receiveFrameWithPostHandshakeDeadlineSync( + _ timeout: TimeInterval = 5 + ) throws -> Data { + let firstByte = try stream.readExactlySync(1) + let deadline = Date().addingTimeInterval(max(0, timeout)) + var header = firstByte + header.append(try stream.readExactlySync(DaemonFrameHeader.byteCount - 1, by: deadline)) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw FramedMessageIO.IOError.invalidHeader + } catch { + throw FramedMessageIO.IOError.payloadTooLarge + } + return length == 0 + ? Data() + : try stream.readExactlySync(length, by: deadline) + } + + public func sendFrame(_ data: Data) async throws { + guard acceptsWrites else { return } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + writeQueue.async { + self.stateLock.lock() + let closed = self.isClosed + self.stateLock.unlock() + guard !closed else { + continuation.resume(throwing: FramedMessageIO.IOError.connectionClosed) + return + } + do { + try self.stream.writeFrameSync(data) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + public func receiveFrameSync() throws -> Data { + let header = try stream.readExactlySync(DaemonFrameHeader.byteCount) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw FramedMessageIO.IOError.invalidHeader + } catch { + throw FramedMessageIO.IOError.payloadTooLarge + } + return length == 0 ? Data() : try stream.readExactlySync(length) + } + + public func sendFrameSync(_ data: Data) throws { + guard acceptsWrites else { return } + try writeQueue.sync { + stateLock.lock() + let closed = isClosed + stateLock.unlock() + guard !closed else { + throw FramedMessageIO.IOError.connectionClosed + } + try stream.writeFrameSync(data) + } + } + + public func close() async throws { + closeSync() + } + + public func closeSync() { + writeQueue.sync { + stateLock.lock() + guard !isClosed else { + stateLock.unlock() + return + } + isClosed = true + stateLock.unlock() + stream.closeSync() + } + } + + public func setReadTimeout(_ timeout: TimeInterval?) { + stream.setReadTimeout(timeout) + } + } + + /// Listener adapter used by the macOS daemon. The bind/unlink policy remains owned by + /// the daemon process; this type owns only the descriptor and accept loop. + public final class UnixSocketListener: @unchecked Sendable, DaemonListener { + public let endpoint: DaemonEndpoint + private let fileDescriptor: Int32 + private let path: String + private let lock = NSLock() + private var isClosed = false + + public init(path: URL, backlog: Int32 = 8) throws { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw FramedMessageIO.IOError.readFailed(errno: errno) + } + self.fileDescriptor = descriptor + self.path = path.path + self.endpoint = .unixSocket(path) + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + guard self.path.utf8.count < MemoryLayout.size - 2 else { + Darwin.close(descriptor) + throw FramedMessageIO.IOError.writeFailed(errno: ENAMETOOLONG) + } + withUnsafeMutablePointer(to: &address.sun_path) { field in + field.withMemoryRebound( + to: CChar.self, capacity: MemoryLayout.size(ofValue: field.pointee) + ) { pointer in + self.path.withCString { + strncpy(pointer, $0, MemoryLayout.size(ofValue: field.pointee) - 1) + } + } + } + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + let code = errno + Darwin.close(descriptor) + throw FramedMessageIO.IOError.writeFailed(errno: code) + } + guard Darwin.listen(descriptor, backlog) == 0 else { + let code = errno + Darwin.close(descriptor) + unlink(self.path) + throw FramedMessageIO.IOError.writeFailed(errno: code) + } + } + + public func accept() async throws -> any DaemonConnection { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + let client = Darwin.accept(self.fileDescriptor, nil, nil) + guard client >= 0 else { + continuation.resume(throwing: FramedMessageIO.IOError.readFailed(errno: errno)) + return + } + continuation.resume( + returning: UnixSocketConnection( + fileDescriptor: client, endpoint: self.endpoint)) + } + } + } + + public func close() async throws { + lock.lock() + let shouldClose = !isClosed + isClosed = true + lock.unlock() + if shouldClose { + Darwin.close(fileDescriptor) + unlink(path) + } + } + } +#endif diff --git a/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift b/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift new file mode 100644 index 00000000..fd00e8be --- /dev/null +++ b/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift @@ -0,0 +1,1613 @@ +import Foundation + +#if os(Windows) + import WinSDK + + /// Errors raised by the Windows daemon transport. Win32 status values are kept + /// intact so callers can distinguish an unavailable daemon from a cancelled + /// operation without parsing localized text. + public enum WindowsPipeError: Error, Equatable, Sendable { + case win32(operation: String, code: UInt32) + case connectionClosed + case timedOut + case writeOutcomeUnknown + case invalidFrame + case invalidPipeName + case serverIdentityRejected + case instanceAlreadyRunning + case rendezvousSecretInUse + + static func isPeerDisconnectCode(_ code: UInt32) -> Bool { + [ + UInt32(truncatingIfNeeded: ERROR_BROKEN_PIPE), + UInt32(truncatingIfNeeded: ERROR_NO_DATA), + UInt32(truncatingIfNeeded: ERROR_PIPE_NOT_CONNECTED), + UInt32(truncatingIfNeeded: ERROR_OPERATION_ABORTED), + UInt32(truncatingIfNeeded: ERROR_CONNECTION_ABORTED), + UInt32(truncatingIfNeeded: ERROR_NETNAME_DELETED), + ].contains(code) + } + + static func classifyWriteCancellation( + cancelSucceeded: Bool, + cancelError: UInt32?, + completionSucceeded: Bool, + completionCode: UInt32, + transferred: UInt32 + ) -> Self { + if completionSucceeded || transferred > 0 { + return .writeOutcomeUnknown + } + if cancelSucceeded, completionCode == UInt32(truncatingIfNeeded: ERROR_OPERATION_ABORTED) { + return .timedOut + } + _ = cancelError + return .writeOutcomeUnknown + } + } + + private final class WindowsPipeHandle: @unchecked Sendable { + let value: HANDLE + + init(_ value: HANDLE) { + self.value = value + } + } + + enum WindowsPipeSecurity { + static func descriptor(for sid: String) -> String { + "D:P(A;;GA;;;\(sid))" + } + + static func attributes(for sid: String) throws -> (SECURITY_ATTRIBUTES, HLOCAL) { + try attributes(descriptorText: descriptor(for: sid)) + } + + static func fileAttributes(for sid: String) throws -> (SECURITY_ATTRIBUTES, HLOCAL) { + try attributes(descriptorText: fileDescriptor(for: sid)) + } + + private static func attributes( + descriptorText: String + ) throws -> (SECURITY_ATTRIBUTES, HLOCAL) { + var descriptor: PSECURITY_DESCRIPTOR? + let success = withWideString(descriptorText) { text in + ConvertStringSecurityDescriptorToSecurityDescriptorW( + text, + DWORD(SDDL_REVISION_1), + &descriptor, + nil) + } + guard success != false, let descriptor else { + throw WindowsPipeError.win32( + operation: "ConvertStringSecurityDescriptorToSecurityDescriptorW", + code: GetLastError()) + } + let attributes = SECURITY_ATTRIBUTES( + nLength: DWORD(MemoryLayout.size), + lpSecurityDescriptor: descriptor, + bInheritHandle: false) + return (attributes, HLOCAL(descriptor)) + } + + static func validate(path: String, sid: String) throws { + var descriptor: PSECURITY_DESCRIPTOR? + let result = withWideString(path) { widePath in + GetNamedSecurityInfoW( + widePath, + SE_FILE_OBJECT, + SECURITY_INFORMATION( + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION), + nil, + nil, + nil, + nil, + &descriptor) + } + guard result == ERROR_SUCCESS, let descriptor else { + throw WindowsPipeError.win32( + operation: "GetNamedSecurityInfoW", code: result) + } + defer { _ = LocalFree(HLOCAL(descriptor)) } + + var text: LPWSTR? + guard + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + DWORD(SDDL_REVISION_1), + SECURITY_INFORMATION( + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION), + &text, + nil), + let text + else { + throw WindowsPipeError.win32( + operation: "ConvertSecurityDescriptorToStringSecurityDescriptorW", + code: GetLastError()) + } + defer { _ = LocalFree(HLOCAL(text)) } + + let sddl = String(decodingCString: text, as: UTF16.self) + guard + ["O:\(sid)", "O:BA", "O:SY"].contains(where: { sddl.hasPrefix($0) }), + isPrivateFileDescriptor(sddl, sid: sid) + else { + throw WindowsPipeError.win32( + operation: "validate rendezvous security", + code: UInt32(truncatingIfNeeded: ERROR_ACCESS_DENIED)) + } + } + + static func isPrivateFileDescriptor(_ sddl: String, sid: String) -> Bool { + guard let daclStart = sddl.range(of: "D:") else { return false } + let dacl = String(sddl[daclStart.lowerBound...]) + guard let aceStart = dacl.firstIndex(of: "(") else { return false } + var flags = String(dacl[dacl.index(dacl.startIndex, offsetBy: 2).. = [] + while !flags.isEmpty { + let flag: String + if flags.hasPrefix("AI") { + flag = "AI" + } else if flags.hasPrefix("AR") { + flag = "AR" + } else if flags.hasPrefix("P") { + flag = "P" + } else { + return false + } + guard seen.insert(flag).inserted else { return false } + flags.removeFirst(flag.count) + } + guard seen.contains("P"), dacl.last == ")" else { return false } + let ace = dacl[dacl.index(after: aceStart).. = [] + let allowedAceFlags = ["CI", "OI", "NP", "IO", "ID", "SA", "FA"] + while !aceFlags.isEmpty { + guard let flag = allowedAceFlags.first(where: { aceFlags.hasPrefix($0) }), + seenAceFlags.insert(flag).inserted + else { + return false + } + aceFlags.removeFirst(flag.count) + } + return fields[0] == "A" + && (fields[2] == "FA" || fields[2].lowercased() == "0x001f01ff") + && fields[3].isEmpty + && fields[4].isEmpty + && acceptedTrustees(for: sid).contains(String(fields[5])) + } + + private static func acceptedTrustees(for sid: String) -> Set { + var trustees = Set([sid]) + if sid == "S-1-5-18" { + trustees.insert("SY") + } + if sid.split(separator: "-").last == "500" { + trustees.insert("LA") + } + return trustees + } + + private static func fileDescriptor(for sid: String) -> String { + "D:P(A;;FA;;;\(sid))" + } + } + + private enum WindowsRendezvousSecret { + static let fileName = ".graphcode-rendezvous.secret" + static let activeGenerationFileName = ".graphcode-active-generation" + static let byteCount = 32 + + static func loadOrCreate( + directory: URL, + sid: String, + stableLockName: String? = nil + ) throws -> Data { + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: file.path) { + if let existing = try? validatedSecretWithRetry(at: file, sid: sid) { + if let stableLockName, isMutexHeld(named: stableLockName) { + guard activeGeneration(in: directory) == GraphcodeSHA256.hex(existing) else { + throw WindowsPipeError.rendezvousSecretInUse + } + } + return existing + } + if let stableLockName, isMutexHeld(named: stableLockName) { + throw WindowsPipeError.rendezvousSecretInUse + } + try rotateInvalidSecret(at: file) + } else if let stableLockName, isMutexHeld(named: stableLockName) { + throw WindowsPipeError.rendezvousSecretInUse + } + do { + return try createSecret(at: file, sid: sid) + } catch WindowsPipeError.win32(_, let code) + where code == UInt32(truncatingIfNeeded: ERROR_FILE_EXISTS) + || code == UInt32(truncatingIfNeeded: ERROR_ALREADY_EXISTS) + { + do { + return try validatedSecret(at: file, sid: sid) + } catch { + if let stableLockName, isMutexHeld(named: stableLockName) { + throw WindowsPipeError.rendezvousSecretInUse + } + throw error + } + } + } + + private static func isMutexHeld(named name: String) -> Bool { + let handle = withWideString(name) { + OpenMutexW(DWORD(SYNCHRONIZE), false, $0) + } + guard let handle else { + return GetLastError() != ERROR_FILE_NOT_FOUND + } + _ = CloseHandle(handle) + return true + } + + static func activeGeneration(in directory: URL) -> String? { + try? String( + contentsOf: directory.appendingPathComponent(activeGenerationFileName), + encoding: .utf8 + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func recordActiveGeneration(directory: URL, generation: String) throws { + try Data(generation.utf8).write( + to: directory.appendingPathComponent(activeGenerationFileName), + options: .atomic) + } + + static func validatedSecret(at file: URL, sid: String) throws -> Data { + try WindowsPipeSecurity.validate(path: file.path, sid: sid) + let secret = try Data(contentsOf: file) + guard secret.count == byteCount, secret.contains(where: { $0 != 0 }) else { + throw WindowsPipeError.win32( + operation: "validate rendezvous secret", + code: UInt32(truncatingIfNeeded: ERROR_INVALID_DATA)) + } + return secret + } + + private static func validatedSecretWithRetry(at file: URL, sid: String) throws -> Data { + var lastError: Error? + for attempt in 0..<20 { + do { + return try validatedSecret(at: file, sid: sid) + } catch WindowsPipeError.win32(_, let code) + where code == UInt32(truncatingIfNeeded: ERROR_INVALID_DATA) + || code == UInt32(truncatingIfNeeded: ERROR_SHARING_VIOLATION) + || code == UInt32(truncatingIfNeeded: ERROR_LOCK_VIOLATION) + { + lastError = WindowsPipeError.win32( + operation: "validate rendezvous secret", + code: code) + } catch { + throw error + } + if attempt < 19 { + Thread.sleep(forTimeInterval: 0.005) + } + } + throw lastError + ?? WindowsPipeError.win32( + operation: "validate rendezvous secret", + code: UInt32(truncatingIfNeeded: ERROR_INVALID_DATA)) + } + + private static func rotateInvalidSecret(at file: URL) throws { + let quarantine = file.deletingLastPathComponent().appendingPathComponent( + "\(file.lastPathComponent).invalid-\(UUID().uuidString)", isDirectory: false) + try FileManager.default.moveItem(at: file, to: quarantine) + try? FileManager.default.removeItem(at: quarantine) + } + + private static func createSecret(at file: URL, sid: String) throws -> Data { + var generator = SystemRandomNumberGenerator() + let secret = Data( + (0.. (sid: String, supportHash: String) { + let sid = try WindowsUserIdentity.currentSID() + let configuredSupport = SupportDirectory.configuredURL( + environment: environment, homeDirectory: homeDirectory) + try FileManager.default.createDirectory( + at: configuredSupport, withIntermediateDirectories: true) + let support = SupportDirectory.url( + environment: environment, homeDirectory: homeDirectory) + let supportHash = GraphcodeSHA256.hex( + Data(support.standardizedFileURL.path.lowercased().utf8)) + return (sid, supportHash) + } + + static func values( + environment: [String: String], + homeDirectory: URL + ) throws -> (sid: String, supportHash: String, rendezvousHash: String) { + let stable = try stableValues( + environment: environment, homeDirectory: homeDirectory) + let configuredSupport = SupportDirectory.configuredURL( + environment: environment, homeDirectory: homeDirectory) + let stableLockName = WindowsDaemonInstanceLock.name( + sid: stable.sid, supportHash: stable.supportHash) + let secret = try WindowsRendezvousSecret.loadOrCreate( + directory: configuredSupport, + sid: stable.sid, + stableLockName: stableLockName) + return (stable.sid, stable.supportHash, GraphcodeSHA256.hex(secret)) + } + + static func taskName( + environment: [String: String], + homeDirectory: URL + ) throws -> String { + let identity = try stableValues( + environment: environment, homeDirectory: homeDirectory) + return taskName( + sid: identity.sid, + supportHash: identity.supportHash) + } + + static func taskName(supportDirectory: URL) throws -> String { + let sid = try WindowsUserIdentity.currentSID() + try FileManager.default.createDirectory( + at: supportDirectory, withIntermediateDirectories: true) + let resolvedSupport = SupportDirectory.url( + environment: [SupportDirectory.environmentKey: supportDirectory.path], + homeDirectory: supportDirectory.deletingLastPathComponent()) + let supportHash = GraphcodeSHA256.hex( + Data(resolvedSupport.standardizedFileURL.path.lowercased().utf8)) + return taskName(sid: sid, supportHash: supportHash) + } + + static func taskName( + sid: String, + supportHash: String + ) -> String { + let material = Data("\(sid)|\(supportHash)".utf8) + let identityHash = GraphcodeSHA256.hex(material) + return "GraphCode\\graphcoded-\(identityHash.prefix(32))" + } + + static func taskName( + sid: String, + supportHash: String, + rendezvousHash: String + ) -> String { + taskName(sid: sid, supportHash: supportHash) + } + } + + /// The SID of the interactive user running graphcode. Named pipes are scoped + /// by this identity rather than by a username, which is mutable and ambiguous + /// in domain accounts. + public enum WindowsUserIdentity { + public static func currentSID() throws -> String { + var token: HANDLE? + guard + OpenProcessToken( + GetCurrentProcess(), + DWORD(TOKEN_QUERY), + &token), + let token + else { + throw WindowsPipeError.win32(operation: "OpenProcessToken", code: GetLastError()) + } + defer { _ = CloseHandle(token) } + + var required: DWORD = 0 + _ = GetTokenInformation(token, TokenUser, nil, 0, &required) + guard required > 0 else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let memory = UnsafeMutableRawPointer.allocate( + byteCount: Int(required), alignment: MemoryLayout.alignment) + defer { memory.deallocate() } + guard + GetTokenInformation( + token, + TokenUser, + memory, + required, + &required) + else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let tokenUser = memory.assumingMemoryBound(to: TOKEN_USER.self).pointee + var stringSID: LPWSTR? + guard ConvertSidToStringSidW(tokenUser.User.Sid, &stringSID), let stringSID else { + throw WindowsPipeError.win32(operation: "ConvertSidToStringSidW", code: GetLastError()) + } + defer { _ = LocalFree(HLOCAL(stringSID)) } + return String(decodingCString: stringSID, as: UTF16.self) + } + } + + /// A collision-resistant per-user endpoint. The support directory is hashed + /// so two side-by-side GraphCode installs cannot share a daemon, while the + /// account SID prevents cross-user collisions and ACL confusion. + public enum WindowsNamedPipeEndpoint { + public static func name( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws -> String { + if let override = try normalizedPipeName(environment: environment) { + return override + } + + let identity = try WindowsNamedPipeIdentity.values( + environment: environment, homeDirectory: homeDirectory) + return + "\\\\.\\pipe\\graphcode-\(identity.sid)-\(identity.supportHash.prefix(24))-\(identity.rendezvousHash.prefix(24))" + } + + public static func taskName( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws -> String { + try WindowsNamedPipeIdentity.taskName( + environment: environment, homeDirectory: homeDirectory) + } + + public static func generation( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws -> String { + if let override = try normalizedPipeName(environment: environment) { + return GraphcodeSHA256.hex(Data(override.utf8)) + } + return try WindowsNamedPipeIdentity.values( + environment: environment, homeDirectory: homeDirectory + ).rendezvousHash + } + + public static func recordActiveGeneration( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws { + if try normalizedPipeName(environment: environment) != nil { + return + } + let support = SupportDirectory.configuredURL( + environment: environment, homeDirectory: homeDirectory) + let generation = try WindowsNamedPipeIdentity.values( + environment: environment, homeDirectory: homeDirectory + ).rendezvousHash + try WindowsRendezvousSecret.recordActiveGeneration( + directory: support, generation: generation) + } + + /// Returns the canonical Windows pipe override, or `nil` when no override + /// was supplied. The canonical form makes equivalent case/whitespace + /// spellings share one endpoint generation. + public static func normalizedPipeName( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> String? { + let rawValue: String? + if let exact = environment[DaemonSocketPath.environmentKey] { + rawValue = exact + } else { + rawValue = environment.keys + .sorted() + .first(where: { + $0.caseInsensitiveCompare(DaemonSocketPath.environmentKey) == .orderedSame + }) + .flatMap { environment[$0] } + } + guard let rawValue else { + return nil + } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "\\\\.\\pipe\\" + guard + value.range(of: prefix, options: [.caseInsensitive, .anchored]) != nil + else { + throw WindowsPipeError.invalidPipeName + } + let suffix = String(value.dropFirst(prefix.count)) + guard + !suffix.isEmpty, + value.utf16.count <= 256, + suffix.unicodeScalars.allSatisfy({ + $0.value >= 0x20 + && $0.value != 0x5C + && $0.value != 0x2F + && $0.value != 0x22 + }) + else { + throw WindowsPipeError.invalidPipeName + } + return prefix + suffix.lowercased() + } + + static func taskName(sid: String, supportHash: String, rendezvousHash: String) -> String { + WindowsNamedPipeIdentity.taskName( + sid: sid, supportHash: supportHash, rendezvousHash: rendezvousHash) + } + + static func taskName(supportDirectory: URL) throws -> String { + try WindowsNamedPipeIdentity.taskName(supportDirectory: supportDirectory) + } + } + + /// A current-user, support-directory-scoped lifetime lock for graphcoded. + public final class WindowsDaemonInstanceLock: @unchecked Sendable { + private let handle: HANDLE + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws { + let identity = try WindowsNamedPipeIdentity.stableValues( + environment: environment, homeDirectory: homeDirectory) + let name = Self.name( + sid: identity.sid, + supportHash: identity.supportHash) + let securityResult = try WindowsPipeSecurity.attributes(for: identity.sid) + var security = securityResult.0 + defer { _ = LocalFree(securityResult.1) } + + guard + let mutex = withWideString( + name, + { wideName in CreateMutexW(&security, true, wideName) }) + else { + throw WindowsPipeError.win32( + operation: "CreateMutexW", code: GetLastError()) + } + let error = GetLastError() + guard error != ERROR_ALREADY_EXISTS else { + _ = CloseHandle(mutex) + throw WindowsPipeError.instanceAlreadyRunning + } + handle = mutex + } + + deinit { + _ = CloseHandle(handle) + } + + static func name(sid: String, supportHash: String) -> String { + "Global\\graphcode-daemon-\(sid)-\(supportHash.prefix(20))" + } + + public static func startupName(sid: String, supportHash: String) -> String { + "\(name(sid: sid, supportHash: supportHash))-startup" + } + + static func name(sid: String, supportHash: String, rendezvousHash: String) -> String { + name(sid: sid, supportHash: supportHash) + } + + static func securityDescriptor(for sid: String) -> String { + WindowsPipeSecurity.descriptor(for: sid) + } + } + + /// Serializes every daemon launch path, including scheduled-task and shell + /// children, before the lifetime instance lock is acquired. + public final class WindowsDaemonStartupReservation: @unchecked Sendable { + private let handle: HANDLE + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) throws { + let identity = try WindowsNamedPipeIdentity.stableValues( + environment: environment, homeDirectory: homeDirectory) + let name = WindowsDaemonInstanceLock.startupName( + sid: identity.sid, supportHash: identity.supportHash) + let securityResult = try WindowsPipeSecurity.attributes(for: identity.sid) + var security = securityResult.0 + defer { _ = LocalFree(securityResult.1) } + + guard + let mutex = withWideString( + name, + { wideName in CreateMutexW(&security, true, wideName) }) + else { + throw WindowsPipeError.win32( + operation: "CreateMutexW", code: GetLastError()) + } + let error = GetLastError() + if error == ERROR_ALREADY_EXISTS { + let result = WaitForSingleObject(mutex, 5_000) + guard result == WAIT_OBJECT_0 else { + _ = CloseHandle(mutex) + throw WindowsPipeError.timedOut + } + } + handle = mutex + } + + deinit { + _ = ReleaseMutex(handle) + _ = CloseHandle(handle) + } + } + + private func withWideString( + _ value: String, + _ body: (UnsafePointer) throws -> Result + ) rethrows -> Result { + var buffer = Array(value.utf16) + buffer.append(0) + return try buffer.withUnsafeBufferPointer { pointer in + try body(pointer.baseAddress!) + } + } + + private func checkPipeHandle(_ handle: HANDLE?, operation: String) throws -> HANDLE { + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw WindowsPipeError.win32(operation: operation, code: GetLastError()) + } + return handle + } + + private func makePipe( + name: String, + userSID: String, + maxInstances: DWORD + ) throws -> HANDLE { + let securityResult = try WindowsPipeSecurity.attributes(for: userSID) + var security = securityResult.0 + defer { _ = LocalFree(securityResult.1) } + return try withWideString(name) { wideName in + try checkPipeHandle( + CreateNamedPipeW( + wideName, + DWORD(PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED), + DWORD(PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT), + maxInstances, + 64 * 1024, + 64 * 1024, + 1_000, + &security), + operation: "CreateNamedPipeW") + } + } + + private final class WindowsPipeOperation: @unchecked Sendable { + let handle: HANDLE + let event: HANDLE + + init(handle: HANDLE) throws { + guard let event = CreateEventW(nil, true, false, nil) else { + throw WindowsPipeError.win32(operation: "CreateEventW", code: GetLastError()) + } + self.handle = handle + self.event = event + } + + deinit { _ = CloseHandle(event) } + + func wait(timeout: DWORD = INFINITE) throws { + let result = WaitForSingleObject(event, timeout) + if result == WAIT_TIMEOUT { + throw WindowsPipeError.timedOut + } + guard result == WAIT_OBJECT_0 else { + throw WindowsPipeError.win32(operation: "WaitForSingleObject", code: GetLastError()) + } + } + } + + /// Overlapped, cancellable byte stream. Every operation is bounded by an + /// explicit event and can be cancelled by closing the connection; this avoids + /// a blocked synchronous ReadFile strand surviving daemon shutdown. + public final class WindowsNamedPipeByteStream: @unchecked Sendable, DaemonByteStream { + private let handle: HANDLE + private let writeTimeout: TimeInterval + private let lock = NSCondition() + private var closed = false + private var activeOperations = 0 + + public init(handle: HANDLE, writeTimeout: TimeInterval = 5) { + self.handle = handle + self.writeTimeout = max(0.001, writeTimeout) + } + + public func readExactly(_ count: Int) async throws -> Data { + guard count >= 0 else { throw WindowsPipeError.invalidFrame } + if count == 0 { return Data() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume(returning: try self.readSynchronously(count)) + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: { + self.cancelPendingIO() + } + } + + public func writeAll(_ data: Data) async throws { + if data.isEmpty { return } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + try self.writeSynchronously( + data, by: Date().addingTimeInterval(self.writeTimeout)) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: { + self.cancelPendingIO() + } + } + + public func close() async throws { + closeSynchronously() + } + + public func closeSynchronously() { + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + _ = CancelIoEx(handle, nil) + while activeOperations > 0 { + lock.wait() + } + lock.unlock() + _ = CloseHandle(handle) + } + + fileprivate func writeFrameSynchronously( + _ data: Data, + timeout: TimeInterval = 5 + ) throws { + let header = try DaemonFrameHeader.encodeLength( + data.count, + maxPayloadBytes: UInt32(DaemonFrameHeader.legacySafetyCeilingBytes)) + let deadline = Date().addingTimeInterval(max(0.001, timeout)) + try writeSynchronously(header, by: deadline) + if !data.isEmpty { + try writeSynchronously(data, by: deadline) + } + } + + private func beginOperation() throws { + lock.lock() + defer { lock.unlock() } + guard !closed else { throw WindowsPipeError.connectionClosed } + activeOperations += 1 + } + + private func endOperation() { + lock.lock() + activeOperations -= 1 + if activeOperations == 0 { lock.broadcast() } + lock.unlock() + } + + fileprivate func cancelPendingIO() { + lock.lock() + let shouldCancel = !closed + lock.unlock() + if shouldCancel { _ = CancelIoEx(handle, nil) } + } + + fileprivate func hasAvailableBytes() throws -> Bool { + try beginOperation() + defer { endOperation() } + + var bytesRead: DWORD = 0 + var bytesAvailable: DWORD = 0 + guard PeekNamedPipe(handle, nil, 0, &bytesRead, &bytesAvailable, nil) else { + let code = GetLastError() + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "PeekNamedPipe", code: code) + } + return bytesAvailable > 0 + } + + fileprivate func readSynchronously(_ count: Int, by deadline: Date? = nil) throws -> Data { + try beginOperation() + defer { endOperation() } + var output = Data() + output.reserveCapacity(count) + var remaining = count + while remaining > 0 { + var chunk = [UInt8](repeating: 0, count: remaining) + var overlapped = OVERLAPPED() + let operation = try WindowsPipeOperation(handle: handle) + overlapped.hEvent = operation.event + var transferred: DWORD = 0 + let succeeded = chunk.withUnsafeMutableBytes { bytes in + ReadFile( + handle, + bytes.baseAddress, + DWORD(remaining), + &transferred, + &overlapped) + } + if !succeeded { + let code = GetLastError() + guard code == ERROR_IO_PENDING else { + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "ReadFile", code: code) + } + } + do { + let timeout = try remainingTimeout(until: deadline) + try operation.wait(timeout: timeout) + } catch WindowsPipeError.timedOut { + withUnsafeMutablePointer(to: &overlapped) { pending in + _ = CancelIoEx(handle, pending) + } + try? operation.wait() + var cancelledBytes: DWORD = 0 + _ = GetOverlappedResult(handle, &overlapped, &cancelledBytes, false) + throw WindowsPipeError.timedOut + } + guard GetOverlappedResult(handle, &overlapped, &transferred, false) else { + let code = GetLastError() + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "GetOverlappedResult", code: code) + } + guard transferred > 0 else { throw WindowsPipeError.connectionClosed } + output.append(contentsOf: chunk.prefix(Int(transferred))) + remaining -= Int(transferred) + } + return output + } + + private func remainingTimeout(until deadline: Date?) throws -> DWORD { + guard let deadline else { return INFINITE } + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { throw WindowsPipeError.timedOut } + return DWORD(min(Double(DWORD.max), max(1, (remaining * 1_000).rounded(.up)))) + } + + private func writeSynchronously(_ data: Data, by deadline: Date? = nil) throws { + try beginOperation() + defer { endOperation() } + var offset = 0 + while offset < data.count { + var overlapped = OVERLAPPED() + let operation = try WindowsPipeOperation(handle: handle) + overlapped.hEvent = operation.event + var transferred: DWORD = 0 + let succeeded = data.withUnsafeBytes { bytes in + WriteFile( + handle, + bytes.baseAddress!.advanced(by: offset), + DWORD(data.count - offset), + &transferred, + &overlapped) + } + if !succeeded { + let code = GetLastError() + guard code == ERROR_IO_PENDING else { + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "WriteFile", code: code) + } + } + do { + try operation.wait(timeout: try remainingTimeout(until: deadline)) + } catch WindowsPipeError.timedOut { + var cancelSucceeded = false + var cancelError: UInt32? + withUnsafeMutablePointer(to: &overlapped) { pending in + cancelSucceeded = CancelIoEx(handle, pending) + cancelError = cancelSucceeded ? nil : GetLastError() + } + try? operation.wait() + var cancelledBytes: DWORD = 0 + let completed = GetOverlappedResult( + handle, &overlapped, &cancelledBytes, false) + let completionCode = completed ? 0 : GetLastError() + throw WindowsPipeError.classifyWriteCancellation( + cancelSucceeded: cancelSucceeded, + cancelError: cancelError, + completionSucceeded: completed, + completionCode: completionCode, + transferred: cancelledBytes) + } + guard GetOverlappedResult(handle, &overlapped, &transferred, false) else { + let code = GetLastError() + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "GetOverlappedResult", code: code) + } + guard transferred > 0 else { throw WindowsPipeError.connectionClosed } + offset += Int(transferred) + } + } + } + + /// A connected named-pipe endpoint with complete-frame write serialization. + public final class WindowsNamedPipeConnection: @unchecked Sendable, DaemonConnection { + public let id: Foundation.UUID + public let endpoint: DaemonEndpoint + private let stream: WindowsNamedPipeByteStream + private let writes = DispatchQueue(label: "com.graphcode.windows-pipe-writes") + private let stateLock = NSLock() + private let writeTimeout: TimeInterval + private var closed = false + + public init( + id: Foundation.UUID = Foundation.UUID(), + handle: HANDLE, + pipeName: String, + readTimeout: TimeInterval? = nil, + writeTimeout: TimeInterval = 5 + ) { + self.id = id + endpoint = .namedPipe(pipeName) + self.writeTimeout = max(0.001, writeTimeout) + stream = WindowsNamedPipeByteStream(handle: handle, writeTimeout: writeTimeout) + _ = readTimeout + } + + public func receiveFrame() async throws -> Data { + try await FramedMessageIO.readFrame(from: stream) + } + + public func receiveFrameWithPostHandshakeDeadline( + _ timeout: TimeInterval = 5 + ) async throws -> Data { + try await withTaskCancellationHandler { + while try !stream.hasAvailableBytes() { + try await Task.sleep(for: .milliseconds(10)) + } + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume( + returning: try self.receiveFrameWithPostHandshakeDeadlineSynchronously(timeout)) + } catch { + continuation.resume(throwing: error) + } + } + + } + } onCancel: { + self.stream.cancelPendingIO() + } + } + + /// Reads the initial protocol frame with a bounded wait for its first byte. + /// Once a client starts speaking, the remaining header and payload share the + /// post-handshake budget used by the daemon's staged reader. + public func receiveFrameWithFirstByteDeadline( + firstByteTimeout: TimeInterval = 5, + postHandshakeTimeout: TimeInterval = 5 + ) async throws -> Data { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume( + returning: try self.receiveFrameWithFirstByteDeadlineSynchronously( + firstByteTimeout: firstByteTimeout, + postHandshakeTimeout: postHandshakeTimeout)) + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: { + self.stream.cancelPendingIO() + } + } + + private func receiveFrameWithFirstByteDeadlineSynchronously( + firstByteTimeout: TimeInterval, + postHandshakeTimeout: TimeInterval + ) throws -> Data { + let firstByte = try stream.readSynchronously( + 1, + by: Date().addingTimeInterval(max(0, firstByteTimeout))) + let deadline = Date().addingTimeInterval(max(0, postHandshakeTimeout)) + var header = firstByte + header.append( + try stream.readSynchronously(DaemonFrameHeader.byteCount - 1, by: deadline)) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw FramedMessageIO.IOError.invalidHeader + } catch { + throw FramedMessageIO.IOError.payloadTooLarge + } + return length == 0 + ? Data() + : try stream.readSynchronously(length, by: deadline) + } + + /// Reads one complete response under a deadline that begins before the first byte. + /// CLI calls use this whole-response budget; daemon protocol reads use the staged + /// method above so an idle connected client remains harmless. + public func receiveFrameWithDeadline(_ timeout: TimeInterval) async throws -> Data { + let deadline = Date().addingTimeInterval(max(0, timeout)) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + continuation.resume( + returning: try self.receiveFrameWithDeadlineSynchronously(deadline)) + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: { + self.stream.cancelPendingIO() + } + } + + private func receiveFrameWithDeadlineSynchronously(_ deadline: Date) throws -> Data { + let header = try stream.readSynchronously( + DaemonFrameHeader.byteCount, by: deadline) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw FramedMessageIO.IOError.invalidHeader + } catch { + throw FramedMessageIO.IOError.payloadTooLarge + } + return length == 0 + ? Data() + : try stream.readSynchronously(length, by: deadline) + } + + private func receiveFrameWithPostHandshakeDeadlineSynchronously( + _ timeout: TimeInterval + ) throws -> Data { + let firstByte = try stream.readSynchronously(1) + let deadline = Date().addingTimeInterval(max(0, timeout)) + var header = firstByte + header.append( + try stream.readSynchronously(DaemonFrameHeader.byteCount - 1, by: deadline)) + let length: Int + do { + length = try DaemonFrameHeader.decodeLength( + Array(header), maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes) + } catch DaemonFrameHeader.HeaderError.invalidHeader { + throw FramedMessageIO.IOError.invalidHeader + } catch { + throw FramedMessageIO.IOError.payloadTooLarge + } + return length == 0 + ? Data() + : try stream.readSynchronously(length, by: deadline) + } + + public func sendFrame(_ data: Data) async throws { + try await withCheckedThrowingContinuation { continuation in + writes.async { + do { + try self.ensureOpen() + try self.stream.writeFrameSynchronously(data, timeout: self.writeTimeout) + continuation.resume() + } catch { + switch error { + case WindowsPipeError.timedOut, WindowsPipeError.writeOutcomeUnknown: + self.closeSynchronously() + default: + break + } + continuation.resume(throwing: error) + } + } + } + } + + public func close() async throws { + closeSynchronously() + } + + private func closeSynchronously() { + stateLock.lock() + guard !closed else { + stateLock.unlock() + return + } + closed = true + stateLock.unlock() + stream.closeSynchronously() + } + + private func ensureOpen() throws { + stateLock.lock() + let isClosed = closed + stateLock.unlock() + if isClosed { throw WindowsPipeError.connectionClosed } + } + } + + /// Limits the number of accepted connections that are still waiting for their + /// initial protocol frame. A same-user client can connect to the pipe, but it + /// cannot consume an unbounded daemon worker forever without sending bytes. + public final class WindowsPipeHandshakeLimiter: @unchecked Sendable { + private let lock = NSLock() + private let limit: Int + private var active = 0 + + public init(limit: Int = 32) { + self.limit = max(1, limit) + } + + public func tryAcquire() -> Permit? { + lock.lock() + defer { lock.unlock() } + guard active < limit else { return nil } + active += 1 + return Permit(owner: self) + } + + private func release() { + lock.lock() + active = max(0, active - 1) + lock.unlock() + } + + public final class Permit: @unchecked Sendable { + private weak var owner: WindowsPipeHandshakeLimiter? + private let lock = NSLock() + private var released = false + + fileprivate init(owner: WindowsPipeHandshakeLimiter) { + self.owner = owner + } + + public func release() { + lock.lock() + guard !released else { + lock.unlock() + return + } + released = true + let owner = self.owner + lock.unlock() + owner?.release() + } + + deinit { release() } + } + } + + /// Listener that creates one overlapped pipe instance per accept. The ACL is + /// applied at CreateNamedPipeW time, and every accepted client is checked for + /// the current-user SID before it is handed to the daemon. + public final class WindowsNamedPipeListener: @unchecked Sendable, DaemonListener { + public let endpoint: DaemonEndpoint + private let name: String + private let sid: String + private let writeTimeout: TimeInterval + private let beforeConnectionReturn: (@Sendable () -> Void)? + private let onPublished: (@Sendable () -> Void)? + private let lock = NSCondition() + private var closed = false + private var published = false + private var pendingHandles: Set = [] + private var transferringHandles: Set = [] + + public init( + pipeName: String? = nil, + backlog: Int = 16, + writeTimeout: TimeInterval = 5, + beforeConnectionReturn: (@Sendable () -> Void)? = nil, + onPublished: (@Sendable () -> Void)? = nil + ) throws { + _ = backlog + name = try pipeName ?? WindowsNamedPipeEndpoint.name() + sid = try WindowsUserIdentity.currentSID() + self.writeTimeout = max(0.001, writeTimeout) + self.beforeConnectionReturn = beforeConnectionReturn + self.onPublished = onPublished + endpoint = .namedPipe(name) + } + + public func accept() async throws -> any DaemonConnection { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + let handle = try self.makeTrackedPipe() + do { + try self.ensureOpen() + try self.connect(handle) + try self.ensureOpen() + try self.verifyClient(handle) + try self.beginTransfer(handle) + self.beforeConnectionReturn?() + let connection = WindowsNamedPipeConnection( + handle: handle, + pipeName: self.name, + writeTimeout: self.writeTimeout) + try self.finishTransfer(handle) + continuation.resume(returning: connection) + } catch { + self.untrack(handle) + _ = DisconnectNamedPipe(handle) + _ = CloseHandle(handle) + throw error + } + } catch { + continuation.resume(throwing: error) + } + } + } + } onCancel: { + self.closePending() + } + } + + public func close() async throws { + closePending() + } + + private func makeTrackedPipe() throws -> HANDLE { + lock.lock() + guard !closed else { + lock.unlock() + throw WindowsPipeError.connectionClosed + } + let handle: HANDLE + do { + handle = try makePipe( + name: name, + userSID: sid, + maxInstances: DWORD(PIPE_UNLIMITED_INSTANCES)) + } catch { + lock.unlock() + throw error + } + pendingHandles.insert(UInt(bitPattern: handle)) + let shouldPublish = !published + published = true + lock.unlock() + if shouldPublish { + onPublished?() + } + return handle + } + + private func untrack(_ handle: HANDLE) { + lock.lock() + pendingHandles.remove(UInt(bitPattern: handle)) + transferringHandles.remove(UInt(bitPattern: handle)) + lock.unlock() + } + + private func beginTransfer(_ handle: HANDLE) throws { + lock.lock() + defer { lock.unlock() } + guard !closed else { + throw WindowsPipeError.connectionClosed + } + let raw = UInt(bitPattern: handle) + guard pendingHandles.remove(raw) != nil else { + throw WindowsPipeError.connectionClosed + } + transferringHandles.insert(raw) + } + + private func finishTransfer(_ handle: HANDLE) throws { + lock.lock() + defer { lock.unlock() } + let raw = UInt(bitPattern: handle) + guard !closed, transferringHandles.remove(raw) != nil else { + throw WindowsPipeError.connectionClosed + } + } + + private func closePending() { + lock.lock() + if closed { + lock.unlock() + return + } + closed = true + let handles = pendingHandles.union(transferringHandles) + lock.unlock() + for raw in handles { + let handle = HANDLE(bitPattern: raw) + _ = CancelIoEx(handle, nil) + } + } + + private func ensureOpen() throws { + lock.lock() + let isClosed = closed + lock.unlock() + if isClosed { + throw WindowsPipeError.connectionClosed + } + } + + private func isOpen() -> Bool { + lock.lock() + defer { lock.unlock() } + return !closed + } + + private func connect(_ handle: HANDLE) throws { + var overlapped = OVERLAPPED() + guard let event = CreateEventW(nil, true, false, nil) else { + throw WindowsPipeError.win32(operation: "CreateEventW", code: GetLastError()) + } + defer { _ = CloseHandle(event) } + overlapped.hEvent = event + let connected = ConnectNamedPipe(handle, &overlapped) + if !connected { + let code = GetLastError() + guard code == ERROR_IO_PENDING || code == ERROR_PIPE_CONNECTED else { + if WindowsPipeError.isPeerDisconnectCode(code) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32(operation: "ConnectNamedPipe", code: code) + } + if code == ERROR_IO_PENDING { + if !isOpen() { + _ = CancelIoEx(handle, nil) + } + let result = WaitForSingleObject(event, INFINITE) + guard result == WAIT_OBJECT_0 else { + throw WindowsPipeError.win32(operation: "WaitForSingleObject", code: GetLastError()) + } + var transferred: DWORD = 0 + guard GetOverlappedResult(handle, &overlapped, &transferred, false) else { + let resultCode = GetLastError() + if WindowsPipeError.isPeerDisconnectCode(resultCode) { + throw WindowsPipeError.connectionClosed + } + throw WindowsPipeError.win32( + operation: "GetOverlappedResult", code: resultCode) + } + } + } + } + + private func verifyClient(_ handle: HANDLE) throws { + var processID: ULONG = 0 + guard GetNamedPipeClientProcessId(handle, &processID) else { + throw WindowsPipeError.win32( + operation: "GetNamedPipeClientProcessId", code: GetLastError()) + } + guard let process = OpenProcess(DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, processID) + else { + throw WindowsPipeError.win32(operation: "OpenProcess", code: GetLastError()) + } + defer { _ = CloseHandle(process) } + var token: HANDLE? + guard + OpenProcessToken( + process, + DWORD(TOKEN_QUERY), + &token), + let token + else { + throw WindowsPipeError.win32(operation: "OpenProcessToken", code: GetLastError()) + } + defer { _ = CloseHandle(token) } + + var required: DWORD = 0 + _ = GetTokenInformation(token, TokenUser, nil, 0, &required) + guard required > 0 else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let memory = UnsafeMutableRawPointer.allocate( + byteCount: Int(required), alignment: MemoryLayout.alignment) + defer { memory.deallocate() } + guard GetTokenInformation(token, TokenUser, memory, required, &required) else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let tokenUser = memory.assumingMemoryBound(to: TOKEN_USER.self).pointee + var clientSID: LPWSTR? + guard ConvertSidToStringSidW(tokenUser.User.Sid, &clientSID), let clientSID else { + throw WindowsPipeError.win32(operation: "ConvertSidToStringSidW", code: GetLastError()) + } + defer { _ = LocalFree(HLOCAL(clientSID)) } + let actual = String(decodingCString: clientSID, as: UTF16.self) + guard actual.caseInsensitiveCompare(sid) == .orderedSame else { + throw WindowsPipeError.serverIdentityRejected + } + } + } + + /// Client-side dial with bounded availability waiting and server-token + /// verification. The server PID is resolved through the pipe itself, then + /// checked against the current user's token before any protocol bytes flow. + public enum WindowsNamedPipeClient { + static func isRetryableWaitCode(_ code: UInt32) -> Bool { + [ + UInt32(truncatingIfNeeded: ERROR_FILE_NOT_FOUND), + UInt32(truncatingIfNeeded: ERROR_PIPE_BUSY), + UInt32(truncatingIfNeeded: ERROR_SEM_TIMEOUT), + ].contains(code) + } + + public static func connect( + to name: String, + timeoutMilliseconds: DWORD = 2_000 + ) throws -> WindowsNamedPipeConnection { + try withWideString(name) { wideName in + let deadline = Date().addingTimeInterval(TimeInterval(timeoutMilliseconds) / 1_000) + let handle: HANDLE + while true { + let remainingMilliseconds = max( + 0, Int(deadline.timeIntervalSinceNow * 1_000)) + guard remainingMilliseconds > 0 else { + throw WindowsPipeError.win32( + operation: "WaitNamedPipeW", code: UInt32(bitPattern: ERROR_SEM_TIMEOUT)) + } + if !WaitNamedPipeW(wideName, DWORD(min(remainingMilliseconds, 100))) { + let code = GetLastError() + guard Self.isRetryableWaitCode(code), Date() < deadline + else { + throw WindowsPipeError.win32(operation: "WaitNamedPipeW", code: code) + } + Thread.sleep(forTimeInterval: 0.01) + continue + } + + if let candidate = CreateFileW( + wideName, + DWORD(GENERIC_READ) | DWORD(bitPattern: GENERIC_WRITE), + 0, + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_OVERLAPPED), + nil), + candidate != INVALID_HANDLE_VALUE + { + handle = candidate + break + } + + let code = GetLastError() + guard Self.isRetryableWaitCode(code), + Date() < deadline + else { + throw WindowsPipeError.win32(operation: "CreateFileW", code: code) + } + Thread.sleep(forTimeInterval: 0.01) + } + do { + var mode = DWORD(PIPE_READMODE_BYTE) + guard SetNamedPipeHandleState(handle, &mode, nil, nil) else { + throw WindowsPipeError.win32( + operation: "SetNamedPipeHandleState", code: GetLastError()) + } + try verifyServer(handle) + return WindowsNamedPipeConnection(handle: handle, pipeName: name) + } catch { + _ = CloseHandle(handle) + throw error + } + } + } + + private static func verifyServer(_ handle: HANDLE) throws { + var processID: ULONG = 0 + guard GetNamedPipeServerProcessId(handle, &processID) else { + throw WindowsPipeError.win32( + operation: "GetNamedPipeServerProcessId", code: GetLastError()) + } + guard let process = OpenProcess(DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, processID) + else { + throw WindowsPipeError.win32(operation: "OpenProcess", code: GetLastError()) + } + defer { _ = CloseHandle(process) } + var token: HANDLE? + guard OpenProcessToken(process, DWORD(TOKEN_QUERY), &token), let token else { + throw WindowsPipeError.win32(operation: "OpenProcessToken", code: GetLastError()) + } + defer { _ = CloseHandle(token) } + var required: DWORD = 0 + _ = GetTokenInformation(token, TokenUser, nil, 0, &required) + guard required > 0 else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let memory = UnsafeMutableRawPointer.allocate( + byteCount: Int(required), alignment: MemoryLayout.alignment) + defer { memory.deallocate() } + guard GetTokenInformation(token, TokenUser, memory, required, &required) else { + throw WindowsPipeError.win32(operation: "GetTokenInformation", code: GetLastError()) + } + let tokenUser = memory.assumingMemoryBound(to: TOKEN_USER.self).pointee + var serverSID: LPWSTR? + guard ConvertSidToStringSidW(tokenUser.User.Sid, &serverSID), let serverSID else { + throw WindowsPipeError.win32(operation: "ConvertSidToStringSidW", code: GetLastError()) + } + defer { _ = LocalFree(HLOCAL(serverSID)) } + let expected = try WindowsUserIdentity.currentSID() + let actual = String(decodingCString: serverSID, as: UTF16.self) + guard actual.caseInsensitiveCompare(expected) == .orderedSame else { + throw WindowsPipeError.serverIdentityRejected + } + } + } +#endif diff --git a/GraphcodeKit/Sources/IPC/WindowsRemoteBridge.swift b/GraphcodeKit/Sources/IPC/WindowsRemoteBridge.swift new file mode 100644 index 00000000..b7d7d880 --- /dev/null +++ b/GraphcodeKit/Sources/IPC/WindowsRemoteBridge.swift @@ -0,0 +1,1310 @@ +import Foundation + +#if os(Windows) + import WinSDK + + /// Stable, sanitized failures from the Windows remote bridge. + public enum WindowsRemoteBridgeError: Error, Equatable, LocalizedError, Sendable { + case invalidConfiguration + case alreadyRunning + case stateUnavailable + case stateOwnershipChanged + case stateExpired + case listenerUnavailable + case sshUnavailable + case remoteForwardUnavailable + case invalidFrame + case frameTooLarge + case invalidCapability + case expiredCapability + case backendUnavailable + + public var errorDescription: String? { + switch self { + case .invalidConfiguration: return "The remote bridge configuration is invalid." + case .alreadyRunning: return "The remote bridge is already running." + case .stateUnavailable: return "The remote bridge state is unavailable." + case .stateOwnershipChanged: return "The remote bridge state owner changed." + case .stateExpired: return "The remote bridge state expired." + case .listenerUnavailable: return "The remote bridge listener is unavailable." + case .sshUnavailable: return "The SSH forward is unavailable." + case .remoteForwardUnavailable: return "The SSH reverse forward could not be verified." + case .invalidFrame: return "The remote bridge received an invalid frame." + case .frameTooLarge: return "The remote bridge frame is too large." + case .invalidCapability: return "The remote bridge capability is invalid." + case .expiredCapability: return "The remote bridge capability expired." + case .backendUnavailable: return "The local graphcoded endpoint is unavailable." + } + } + } + + /// A user-only, atomically replaced state record store. + public final class WindowsRemoteBridgeStateStore: @unchecked Sendable { + private static let lockGuard = NSLock() + private static nonisolated(unsafe) var locks: [String: NSLock] = [:] + + public let url: URL + private let sid: String + private let processLock: NSLock + private let namedLock: HANDLE + + public init(url: URL, lockURL: URL? = nil) throws { + self.url = url + sid = try WindowsUserIdentity.currentSID() + let key = (lockURL ?? url).standardizedFileURL.path.lowercased() + Self.lockGuard.lock() + processLock = + Self.locks[key] + ?? { + let lock = NSLock() + Self.locks[key] = lock + return lock + }() + Self.lockGuard.unlock() + namedLock = try Self.makeNamedLock(for: key, sid: sid) + } + + deinit { + _ = CloseHandle(namedLock) + } + + public func transaction( + _ body: () throws -> Result + ) throws -> Result { + processLock.lock() + defer { processLock.unlock() } + let wait = WaitForSingleObject(namedLock, INFINITE) + guard wait == WAIT_OBJECT_0 || wait == DWORD(0x80) else { + throw WindowsRemoteBridgeError.stateUnavailable + } + defer { _ = ReleaseMutex(namedLock) } + return try body() + } + + public func read() throws -> RemoteBridgeWireState { + try transaction { + try readUnlocked() + } + } + + public func readGeneration() throws -> UInt64? { + try transaction { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + guard + let text = String(data: data, encoding: .ascii)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let generation = UInt64(text), + generation > 0 + else { + throw WindowsRemoteBridgeError.stateUnavailable + } + return generation + } + } + + public func reserveGeneration(after baseline: UInt64 = 0) throws -> UInt64 { + try transaction { + let current = try readGenerationUnlocked() ?? 0 + let highest = max(current, baseline) + guard highest < UInt64.max else { + throw WindowsRemoteBridgeError.stateUnavailable + } + let next = highest + 1 + try writeBytesUnlocked(Data(String(next).utf8)) + return next + } + } + + public func write(_ state: RemoteBridgeWireState) throws { + try state.validated() + try transaction { + try writeUnlocked(state) + } + } + + public func writeIfMatches( + _ expected: RemoteBridgeWireState?, _ state: RemoteBridgeWireState + ) throws -> Bool { + try state.validated() + return try transaction { + let current = try? readUnlocked() + guard Self.matches(current, expected) else { return false } + try writeUnlocked(state) + return true + } + } + + public func removeIfMatches(_ expected: RemoteBridgeWireState) throws -> Bool { + try transaction { + guard let current = try? readUnlocked(), Self.matches(current, expected) else { + return false + } + try? FileManager.default.removeItem(at: url) + return true + } + } + + private func readUnlocked() throws -> RemoteBridgeWireState { + var lastError: Error? + for attempt in 0..<20 { + do { + let data = try Data(contentsOf: url) + let state = try JSONDecoder().decode(RemoteBridgeWireState.self, from: data) + try state.validated() + return state + } catch { + lastError = error + if attempt < 19 { Thread.sleep(forTimeInterval: 0.005) } + } + } + throw lastError ?? WindowsRemoteBridgeError.stateUnavailable + } + + private func readGenerationUnlocked() throws -> UInt64? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + guard + let text = String(data: data, encoding: .ascii)? + .trimmingCharacters(in: .whitespacesAndNewlines), + let generation = UInt64(text), + generation > 0 + else { + throw WindowsRemoteBridgeError.stateUnavailable + } + return generation + } + + private func writeUnlocked(_ state: RemoteBridgeWireState) throws { + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(state) + try writeBytesUnlocked(data) + } + + private func writeBytesUnlocked(_ data: Data) throws { + let directory = url.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let temporary = directory.appendingPathComponent( + ".\(url.lastPathComponent).\(UUID().uuidString).tmp") + try writeUserOnlyFile(data, to: temporary) + defer { try? FileManager.default.removeItem(at: temporary) } + + var source = Array(temporary.path.utf16) + source.append(0) + var destination = Array(url.path.utf16) + destination.append(0) + var replaced = false + for attempt in 0..<20 { + replaced = source.withUnsafeBufferPointer { source in + destination.withUnsafeBufferPointer { destination in + MoveFileExW( + source.baseAddress, + destination.baseAddress, + DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + } + } + if replaced { break } + let error = GetLastError() + guard + error == ERROR_SHARING_VIOLATION || error == ERROR_ACCESS_DENIED, + attempt < 19 + else { break } + Thread.sleep(forTimeInterval: 0.005) + } + guard replaced else { throw WindowsRemoteBridgeError.stateUnavailable } + try WindowsPipeSecurity.validate(path: url.path, sid: sid) + } + + private static func matches( + _ current: RemoteBridgeWireState?, _ expected: RemoteBridgeWireState? + ) -> Bool { + switch (current, expected) { + case (nil, nil): return true + case (.some(let current), .some(let expected)): + return current.daemonInstanceID == expected.daemonInstanceID + && current.generation == expected.generation + && constantTimeEqual(current.capability, expected.capability) + default: return false + } + } + + private static func makeNamedLock(for key: String, sid: String) throws -> HANDLE { + let name = "Global\\graphcode-remote-bridge-\(GraphcodeSHA256.hex(Data(key.utf8)).prefix(32))" + let securityResult = try WindowsPipeSecurity.attributes(for: sid) + var security = securityResult.0 + defer { _ = LocalFree(securityResult.1) } + let handle = withWideString(name) { + CreateMutexW(&security, false, $0) + } + guard let handle else { + throw WindowsRemoteBridgeError.stateUnavailable + } + return handle + } + + private func writeUserOnlyFile(_ data: Data, to url: URL) throws { + let attributes = try WindowsPipeSecurity.fileAttributes(for: sid) + var security = attributes.0 + defer { _ = LocalFree(attributes.1) } + let handle = withWideString(url.path) { path in + CreateFileW( + path, + DWORD(GENERIC_WRITE), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + &security, + DWORD(CREATE_NEW), + DWORD(FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_TEMPORARY), + nil) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw WindowsRemoteBridgeError.stateUnavailable + } + defer { _ = CloseHandle(handle) } + var written: DWORD = 0 + let succeeded = data.withUnsafeBytes { bytes in + WriteFile(handle, bytes.baseAddress, DWORD(data.count), &written, nil) + } + guard succeeded, written == DWORD(data.count), FlushFileBuffers(handle) else { + throw WindowsRemoteBridgeError.stateUnavailable + } + } + } + + /// A framed loopback listener. It forwards an authenticated session bidirectionally + /// to one existing Named Pipe client, so open-project, command, response, and event + /// frames retain their daemon connection semantics. + public final class WindowsRemoteBridgeListener: @unchecked Sendable { + public let port: UInt16 + + private let pipeName: String + private let state: @Sendable () -> RemoteBridgeWireState? + private let timeout: TimeInterval + private let maxConnections: Int + private let lock = NSLock() + private var listener: SOCKET + private var stopped = false + private var clients: Set = [] + private let workers = DispatchGroup() + + public init( + requestedPort: UInt16 = 0, + pipeName: String, + timeout: TimeInterval = 5, + maxConnections: Int = 32, + state: @escaping @Sendable () -> RemoteBridgeWireState? + ) throws { + guard timeout.isFinite, timeout > 0, maxConnections > 0 else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + startWinsock() + let socket = try bind(requestedPort: requestedPort) + listener = socket.socket + port = socket.port + self.pipeName = pipeName + self.timeout = timeout + self.maxConnections = maxConnections + self.state = state + } + + deinit { + stop() + } + + public func start() { + lock.lock() + guard !stopped else { + lock.unlock() + return + } + lock.unlock() + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.acceptLoop() + } + } + + public func stop() { + lock.lock() + guard !stopped else { + lock.unlock() + return + } + stopped = true + let socket = listener + listener = INVALID_SOCKET + let active = clients + clients.removeAll() + lock.unlock() + if socket != INVALID_SOCKET { _ = closesocket(socket) } + for raw in active { _ = closesocket(SOCKET(raw)) } + _ = workers.wait(timeout: .now() + 2) + } + + private func acceptLoop() { + var mode: u_long = 1 + lock.lock() + let socket = listener + lock.unlock() + guard socket != INVALID_SOCKET else { return } + _ = ioctlsocket(socket, FIONBIO, &mode) + while true { + lock.lock() + let shouldStop = stopped + lock.unlock() + if shouldStop { return } + + let client = accept(socket, nil, nil) + if client == INVALID_SOCKET { + let code = WSAGetLastError() + if code == WSAEWOULDBLOCK || code == WSAEINPROGRESS { + Thread.sleep(forTimeInterval: 0.02) + continue + } + return + } + + lock.lock() + let reject = stopped || clients.count >= maxConnections + if !reject { clients.insert(UInt64(client)) } + lock.unlock() + if reject { + _ = closesocket(client) + continue + } + workers.enter() + DispatchQueue.global(qos: .utility).async { [weak self] in + defer { self?.workers.leave() } + self?.handle(client) + } + } + } + + private func handle(_ client: SOCKET) { + defer { + lock.lock() + clients.remove(UInt64(client)) + lock.unlock() + _ = closesocket(client) + } + do { + let frame = try readFrame(from: client, timeout: timeout) + let requestData = try authenticatedRequest(from: frame) + let backend = try WindowsNamedPipeClient.connect( + to: pipeName, + timeoutMilliseconds: DWORD(max(1, Int(timeout * 1_000)))) + defer { Task { try? await backend.close() } } + try blocking { + try await backend.sendFrame(requestData) + } + + let stop = WindowsRemoteBridgeRelayStop(socket: client) + let writeLock = DispatchQueue(label: "com.graphcode.remote-bridge-writes") + let finished = DispatchSemaphore(value: 0) + let inbound = Task { [weak self] in + defer { + stop.request() + Task { try? await backend.close() } + finished.signal() + } + do { + while !stop.isRequested { + guard let self else { return } + let frame = try readFrame(from: client) + let request = try self.authenticatedRequest(from: frame) + try await backend.sendFrame(request) + } + } catch WindowsRemoteBridgeError.invalidCapability { + try? self?.sendError( + "invalid_capability", to: client, lock: writeLock) + } catch WindowsRemoteBridgeError.expiredCapability { + try? self?.sendError( + "expired_capability", to: client, lock: writeLock) + } catch WindowsRemoteBridgeError.frameTooLarge { + try? self?.sendError( + "frame_too_large", to: client, lock: writeLock) + } catch { + return + } + } + let outbound = Task { [weak self] in + defer { + stop.request() + Task { try? await backend.close() } + finished.signal() + } + do { + while !stop.isRequested { + let response = try await backend.receiveFrame() + guard let self else { return } + try writeLock.sync { + try writeFrame(response, to: client, timeout: self.timeout) + } + } + } catch { + return + } + } + _ = inbound + _ = outbound + finished.wait() + inbound.cancel() + outbound.cancel() + stop.request() + try? blocking { + try await backend.close() + } + } catch WindowsRemoteBridgeError.frameTooLarge { + try? sendError("frame_too_large", to: client) + } catch WindowsRemoteBridgeError.invalidCapability { + try? sendError("invalid_capability", to: client) + } catch WindowsRemoteBridgeError.expiredCapability { + try? sendError("expired_capability", to: client) + } catch WindowsRemoteBridgeError.stateUnavailable { + try? sendError("state_unavailable", to: client) + } catch WindowsRemoteBridgeError.invalidFrame { + try? sendError("invalid_frame", to: client) + } catch { + try? sendError( + error is FramedMessageIO.IOError ? "invalid_frame" : "backend_unavailable", + to: client) + } + } + + private func authenticatedRequest(from frame: Data) throws -> Data { + guard + let object = try JSONSerialization.jsonObject(with: frame) as? [String: Any], + let capability = object["capability"] as? String, + let generationNumber = object["generation"] as? NSNumber, + let request = object["request"] as? [String: Any] + else { + throw WindowsRemoteBridgeError.invalidFrame + } + let generationType = String(cString: generationNumber.objCType) + let integerTypes = ["i", "s", "l", "q", "I", "S", "L", "Q"] + let generation = generationNumber.uint64Value + guard integerTypes.contains(generationType), + generationNumber.doubleValue > 0, + generationNumber.doubleValue == Double(generation) + else { + throw WindowsRemoteBridgeError.invalidCapability + } + guard let current = state() else { + throw WindowsRemoteBridgeError.stateUnavailable + } + let credential = credentialError( + current: current, capability: capability, generation: generation) + if let credential { + switch credential { + case "expired_capability": throw WindowsRemoteBridgeError.expiredCapability + default: throw WindowsRemoteBridgeError.invalidCapability + } + } + return try JSONSerialization.data( + withJSONObject: request, options: [.sortedKeys]) + } + + private func credentialError( + current: RemoteBridgeWireState, + capability: String, + generation: UInt64 + ) -> String? { + let now = Date().timeIntervalSince1970 + guard current.expiresAt > now else { return "expired_capability" } + if generation == current.generation, + constantTimeEqual(capability, current.capability) + { + return nil + } + if let previous = current.previous, + previous.generation == generation, + previous.expiresAt > now, + constantTimeEqual(capability, previous.capability) + { + return nil + } + return "invalid_capability" + } + + private func sendError( + _ error: String, to socket: SOCKET, lock: DispatchQueue? = nil + ) throws { + let payload = try JSONSerialization.data( + withJSONObject: ["ok": false, "error": error], + options: [.sortedKeys]) + if let lock { + try lock.sync { + try writeFrame(payload, to: socket, timeout: timeout) + } + } else { + try writeFrame(payload, to: socket, timeout: timeout) + } + } + } + + private final class WindowsRemoteBridgeRelayStop: @unchecked Sendable { + private let lock = NSLock() + private let socket: SOCKET + private var requested = false + + init(socket: SOCKET) { + self.socket = socket + } + + var isRequested: Bool { + lock.lock() + defer { lock.unlock() } + return requested + } + + func request() { + lock.lock() + requested = true + lock.unlock() + _ = shutdown(socket, SD_BOTH) + } + } + + /// Production Windows remote bridge and per-authority SSH reverse forward owner. + public actor WindowsRemoteBridge: RemoteBridge, WindowsRemoteBridgeService { + public static let defaultTTL: TimeInterval = 30 * 60 + public static let defaultPreviousOverlap: TimeInterval = 5 + + private final class Entry: @unchecked Sendable { + let authority: WindowsSSHAuthority + let store: WindowsRemoteBridgeStateStore + let listener: WindowsRemoteBridgeListener + var session: WindowsSSHForwardSession? + var state: RemoteBridgeWireState + + init( + authority: WindowsSSHAuthority, + store: WindowsRemoteBridgeStateStore, + listener: WindowsRemoteBridgeListener, + state: RemoteBridgeWireState + ) { + self.authority = authority + self.store = store + self.listener = listener + self.state = state + } + } + + private let supportDirectory: URL + private let pipeName: String + private let ttl: TimeInterval + private let overlap: TimeInterval + private let maxOverlap: TimeInterval + private let ssh: WindowsSSHForwardDriver + private let instanceID = Foundation.UUID() + private var entries: [String: Entry] = [:] + + public init( + supportDirectory: URL = SupportDirectory.url, + pipeName: String? = nil, + ttl: TimeInterval = WindowsRemoteBridge.defaultTTL, + previousOverlap: TimeInterval = WindowsRemoteBridge.defaultPreviousOverlap, + maxPreviousOverlap: TimeInterval = 5, + ssh: WindowsSSHForwardDriver = WindowsSSHForwardDriver() + ) throws { + guard ttl.isFinite, ttl > 0, + previousOverlap.isFinite, previousOverlap >= 0, + maxPreviousOverlap.isFinite, maxPreviousOverlap >= 0 + else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + self.supportDirectory = supportDirectory + self.pipeName = try pipeName ?? WindowsNamedPipeEndpoint.name() + self.ttl = ttl + self.overlap = previousOverlap + self.maxOverlap = maxPreviousOverlap + self.ssh = ssh + } + + deinit { + for entry in entries.values { + Self.teardown(entry) + _ = try? entry.store.removeIfMatches(entry.state) + } + } + + public func shutdown() { + let retained = Array(entries.values) + entries.removeAll() + for entry in retained { + Self.teardown(entry) + _ = try? entry.store.removeIfMatches(entry.state) + } + } + + public func ensureForwarding(authority: String) async throws -> RemoteBridgeState { + guard let authority = WindowsSSHAuthority(authority: authority) else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + return try await ensureForwarding(authority: authority) + } + + public func ensureForwarding(authority: WindowsSSHAuthority) async throws + -> RemoteBridgeState + { + guard !authority.host.isEmpty else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + let key = authority.key + if let entry = entries[key] { + if Date().timeIntervalSince1970 < entry.state.expiresAt { + if let session = entry.session, session.isRunning { + if (try? verifySSH(authority: authority, port: entry.state.port, session: session)) + == true + { + return entry.state.remoteBridgeState() + } + session.stopAndWait() + entry.session = nil + } + if let session = try reconnect(entry, authority: authority) { + entry.session = session + return entry.state.remoteBridgeState() + } + throw WindowsRemoteBridgeError.sshUnavailable + } + Self.teardown(entry) + _ = try? entry.store.removeIfMatches(entry.state) + let replacement = try startEntry( + authority: authority, store: entry.store, after: entry.state.generation) + entries[key] = replacement + return replacement.state.remoteBridgeState() + } + + let store = try WindowsRemoteBridgeStateStore( + url: Self.stateURL(authority: authority, supportDirectory: supportDirectory)) + let previous = try? store.read() + let entry = try startEntry( + authority: authority, store: store, after: previous?.generation ?? 0) + entries[key] = entry + return entry.state.remoteBridgeState() + } + + public func stopForwarding(authority: String) async throws { + guard let authority = WindowsSSHAuthority(authority: authority) else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + try await stopForwarding(authority: authority) + } + + public func stopForwarding(authority: WindowsSSHAuthority) async throws { + guard let entry = entries.removeValue(forKey: authority.key) else { + let store = try WindowsRemoteBridgeStateStore( + url: Self.stateURL(authority: authority, supportDirectory: supportDirectory)) + if let state = try? store.read() { + let expired = state.expiresAt <= Date().timeIntervalSince1970 + if expired || !Self.loopbackReachable(state.port) { + _ = try? store.removeIfMatches(state) + } + } + return + } + Self.teardown(entry) + _ = try? entry.store.removeIfMatches(entry.state) + } + + private static func teardown(_ entry: Entry) { + entry.listener.stop() + entry.session?.stopAndWait() + } + + public func rotate( + authority: String, overlapSeconds: TimeInterval? = nil + ) throws -> RemoteBridgeState { + guard let authority = WindowsSSHAuthority(authority: authority), + let entry = entries[authority.key] + else { + throw WindowsRemoteBridgeError.stateUnavailable + } + return try rotateEntry(entry, overlapSeconds: overlapSeconds) + } + + public static func stateURL(authority: String, supportDirectory: URL) -> URL { + stateURL( + authority: WindowsSSHAuthority(authority: authority) + ?? WindowsSSHAuthority(host: authority), + supportDirectory: supportDirectory) + } + + public static func generationURL(authority: String, supportDirectory: URL) -> URL { + generationURL( + authority: WindowsSSHAuthority(authority: authority) + ?? WindowsSSHAuthority(host: authority), + supportDirectory: supportDirectory) + } + + public static func stateURL( + authority: WindowsSSHAuthority, supportDirectory: URL + ) -> URL { + supportDirectory + .appendingPathComponent("remote-bridges", isDirectory: true) + .appendingPathComponent( + "\(GraphcodeSHA256.hex(Data(authority.key.utf8))).json", + isDirectory: false) + } + + public static func generationURL( + authority: WindowsSSHAuthority, supportDirectory: URL + ) -> URL { + supportDirectory + .appendingPathComponent("remote-bridges", isDirectory: true) + .appendingPathComponent( + "\(GraphcodeSHA256.hex(Data(authority.key.utf8))).generation", + isDirectory: false) + } + + private func startEntry( + authority: WindowsSSHAuthority, + store: WindowsRemoteBridgeStateStore, + after baseline: UInt64 + ) throws -> Entry { + let generationStore = try WindowsRemoteBridgeStateStore( + url: Self.generationURL(authority: authority, supportDirectory: supportDirectory), + lockURL: store.url) + let generation = try generationStore.reserveGeneration(after: baseline) + var lastError: Error? + for _ in 0..<4 { + do { + let listener = try WindowsRemoteBridgeListener( + pipeName: pipeName, + state: { [weak store] in try? store?.read() }) + let now = Date().timeIntervalSince1970 + let state = RemoteBridgeWireState( + daemonInstanceID: instanceID, + generation: generation, + port: listener.port, + capability: Self.newCapability(), + issuedAt: now, + expiresAt: now + ttl) + let entry = Entry( + authority: authority, store: store, listener: listener, state: state) + let prior = try? store.read() + if let prior, prior.expiresAt > now, Self.loopbackReachable(prior.port) { + listener.stop() + throw WindowsRemoteBridgeError.stateOwnershipChanged + } + guard try store.writeIfMatches(prior, state) else { + listener.stop() + throw WindowsRemoteBridgeError.stateOwnershipChanged + } + do { + let session = try ssh.open(authority: authority, port: listener.port) + _ = try verifySSH(authority: authority, port: listener.port, session: session) + entry.session = session + listener.start() + return entry + } catch { + listener.stop() + _ = try? store.removeIfMatches(state) + throw error + } + } catch { + lastError = error + guard + let bridgeError = error as? WindowsRemoteBridgeError, + bridgeError == .sshUnavailable || bridgeError == .remoteForwardUnavailable + else { throw error } + } + } + throw lastError ?? WindowsRemoteBridgeError.sshUnavailable + } + + private func reconnect( + _ entry: Entry, authority: WindowsSSHAuthority + ) throws -> WindowsSSHForwardSession? { + for attempt in 0..<3 { + do { + let session = try ssh.open(authority: authority, port: entry.state.port) + _ = try verifySSH(authority: authority, port: entry.state.port, session: session) + return session + } catch { + if attempt < 2 { Thread.sleep(forTimeInterval: Double(attempt + 1)) } + } + } + return nil + } + + private func rotateEntry( + _ entry: Entry, overlapSeconds: TimeInterval? = nil + ) throws -> RemoteBridgeState { + let requested = overlapSeconds ?? overlap + guard requested.isFinite, requested >= 0 else { + throw WindowsRemoteBridgeError.invalidConfiguration + } + let bounded = min(requested, maxOverlap) + let current = try entry.store.read() + guard current.daemonInstanceID == entry.state.daemonInstanceID, + current.generation == entry.state.generation, + constantTimeEqual(current.capability, entry.state.capability) + else { + throw WindowsRemoteBridgeError.stateOwnershipChanged + } + let now = Date().timeIntervalSince1970 + guard current.expiresAt > now else { throw WindowsRemoteBridgeError.stateExpired } + let generationStore = try WindowsRemoteBridgeStateStore( + url: Self.generationURL(authority: entry.authority, supportDirectory: supportDirectory), + lockURL: entry.store.url) + let generation = try generationStore.reserveGeneration(after: current.generation) + let next = RemoteBridgeWireState( + daemonInstanceID: instanceID, + generation: generation, + port: current.port, + capability: Self.newCapability(), + issuedAt: now, + expiresAt: now + ttl, + previous: bounded > 0 + ? RemoteBridgePreviousWireState( + generation: current.generation, + capability: current.capability, + expiresAt: min(current.expiresAt, now + bounded)) + : nil) + guard try entry.store.writeIfMatches(current, next) else { + throw WindowsRemoteBridgeError.stateOwnershipChanged + } + entry.state = next + return next.remoteBridgeState() + } + + private func verifySSH( + authority: WindowsSSHAuthority, + port: UInt16, + session: WindowsSSHForwardSession + ) throws -> Bool { + guard session.isRunning else { throw WindowsRemoteBridgeError.sshUnavailable } + guard try ssh.verify(authority: authority, port: port) else { + session.stop() + throw WindowsRemoteBridgeError.remoteForwardUnavailable + } + return true + } + + private static func newCapability() -> String { + var generator = SystemRandomNumberGenerator() + return (0..<32).map { _ in + String(format: "%02x", UInt8.random(in: UInt8(0)...UInt8(255), using: &generator)) + }.joined() + } + + private static func loopbackReachable(_ port: UInt16) -> Bool { + startWinsock() + let client = socket(AF_INET, Int32(SOCK_STREAM), Int32(IPPROTO_TCP.rawValue)) + guard client != INVALID_SOCKET else { return false } + defer { _ = closesocket(client) } + var address = sockaddr_in() + address.sin_family = ADDRESS_FAMILY(AF_INET) + address.sin_port = htons(port) + address.sin_addr.S_un.S_addr = UInt32(0x0100_007F) + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + WinSDK.connect(client, $0, Int32(MemoryLayout.size)) + } + } + return result == 0 + } + } + + public final class WindowsSSHForwardSession: @unchecked Sendable { + private let process: Process + + init(process: Process) { + self.process = process + } + + public var isRunning: Bool { process.isRunning } + + public func stop() { + stopAndWait() + } + + public func stopAndWait() { + guard process.isRunning else { return } + process.terminate() + process.waitUntilExit() + } + } + + /// Injectable SSH boundary for controlled local fixtures and production OpenSSH. + public struct WindowsSSHForwardDriver: @unchecked Sendable { + private let opener: @Sendable (WindowsSSHAuthority, UInt16) throws -> WindowsSSHForwardSession + private let verifier: @Sendable (WindowsSSHAuthority, UInt16) throws -> Bool + + public init( + opener: + @escaping @Sendable (WindowsSSHAuthority, UInt16) + throws -> WindowsSSHForwardSession? = { + authority, port in + try WindowsSSHForwardDriver.openDefault(authority: authority, port: port) + }, + verifier: @escaping @Sendable (WindowsSSHAuthority, UInt16) throws -> Bool = { + authority, port in + try WindowsSSHForwardDriver.verifyDefault(authority: authority, port: port) + } + ) { + self.opener = { authority, port in + guard let session = try opener(authority, port) else { + throw WindowsRemoteBridgeError.sshUnavailable + } + return session + } + self.verifier = verifier + } + + func open(authority: WindowsSSHAuthority, port: UInt16) throws -> WindowsSSHForwardSession { + try opener(authority, port) + } + + func verify(authority: WindowsSSHAuthority, port: UInt16) throws -> Bool { + try verifier(authority, port) + } + + public static func openDefault( + authority: WindowsSSHAuthority, port: UInt16 + ) throws -> WindowsSSHForwardSession? { + guard let executable = sshExecutable() else { return nil } + let arguments = forwardingArguments(for: authority, port: port) + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + return WindowsSSHForwardSession(process: process) + } + + static func forwardingArguments( + for authority: WindowsSSHAuthority, port: UInt16 + ) -> [String] { + var arguments = [ + "-o", "StrictHostKeyChecking=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "GatewayPorts=no", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", + "-N", + "-R", "127.0.0.1:\(port):127.0.0.1:\(port)", + ] + arguments += commonArguments(for: authority) + return arguments + } + + public static func verifyDefault( + authority: WindowsSSHAuthority, port: UInt16 + ) throws -> Bool { + guard let executable = sshExecutable() else { return false } + let python = """ + import ipaddress + import shutil + import socket + import subprocess + import sys + + p = int(sys.argv[1]) + s = socket.create_connection(("127.0.0.1", p), 2) + s.close() + + def proc(path, v6): + try: + rows = open(path, encoding="ascii").read().splitlines()[1:] + except OSError: + return None + for row in rows: + fields = row.split() + if len(fields) < 4 or fields[3] != "0A": + continue + host, raw_port = fields[1].split(":") + if int(raw_port, 16) != p: + continue + raw = bytes.fromhex(host) + if v6: + raw = b"".join(raw[i:i + 4][::-1] for i in range(0, 16, 4)) + address = ipaddress.IPv6Address(raw) + else: + address = ipaddress.IPv4Address(raw[::-1]) + return address.is_loopback + return False + + proc_seen = False + for path, v6 in (("/proc/net/tcp", False), ("/proc/net/tcp6", True)): + result = proc(path, v6) + if result is not None: + proc_seen = True + if result: + sys.exit(0) + + def command(name, args): + tool = shutil.which(name) + if not tool: + return None + output = subprocess.run([tool] + args, capture_output=True, text=True) + if output.returncode != 0: + sys.exit(1) + return output.stdout.splitlines() + + lines = command("lsof", ["-nP", "-iTCP:" + str(p), "-sTCP:LISTEN"]) + if lines is not None: + sys.exit(0 if any( + "127.0.0.1:" + str(p) in line or "[::1]:" + str(p) in line + for line in lines + ) else 1) + + lines = command("netstat", ["-an"]) + if lines is not None: + sys.exit(0 if any( + "LISTEN" in line and ( + "127.0.0.1." + str(p) in line + or "127.0.0.1:" + str(p) in line + or "::1." + str(p) in line + or "::1:" + str(p) in line + ) + for line in lines + ) else 1) + if proc_seen: + sys.exit(1) + sys.exit(1) + """ + let arguments = verificationArguments( + for: authority, port: port, python: python) + let request = ProcessRequest( + executable: executable, + arguments: arguments) + let semaphore = DispatchSemaphore(value: 0) + let result = LockedResult() + Task { + do { + result.store( + .success( + try await FoundationProcessRunner().run( + request, timeout: .seconds(5)))) + } catch { + result.store(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + return (try? result.value().exitCode) == 0 + } + + static func verificationArguments( + for authority: WindowsSSHAuthority, port: UInt16, python: String + ) -> [String] { + var arguments = [ + "-o", "StrictHostKeyChecking=yes", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + ] + arguments += commonArguments(for: authority) + arguments += ["python3", "-c", python, String(port)] + return arguments + } + + static func commonArguments(for authority: WindowsSSHAuthority) -> [String] { + var arguments: [String] = [] + if let port = authority.port { + arguments += ["-p", String(port)] + } + arguments.append(authority.destination) + return arguments + } + + private static func sshExecutable() -> URL? { + SSHExecutableResolver.executableURL() + } + } + + private final class LockedResult: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func store(_ result: Result) { + lock.lock() + self.result = result + lock.unlock() + } + + func value() throws -> Value { + lock.lock() + defer { lock.unlock() } + guard let result else { throw WindowsRemoteBridgeError.sshUnavailable } + return try result.get() + } + } + + private func readFrame( + from socket: SOCKET, timeout: TimeInterval? = nil + ) throws -> Data { + let deadline = timeout.map { Date().addingTimeInterval($0) } + let header = try readExactly(4, from: socket, deadline: deadline) + let bytes = [UInt8](header) + let length = Int(bytes[0]) << 24 | Int(bytes[1]) << 16 | Int(bytes[2]) << 8 | Int(bytes[3]) + guard length >= 0, length <= Int(DaemonFrameHeader.legacySafetyCeilingBytes) else { + throw WindowsRemoteBridgeError.frameTooLarge + } + return length == 0 ? Data() : try readExactly(length, from: socket, deadline: deadline) + } + + private func writeFrame( + _ data: Data, to socket: SOCKET, timeout: TimeInterval + ) throws { + guard data.count <= Int(DaemonFrameHeader.legacySafetyCeilingBytes) else { + throw WindowsRemoteBridgeError.frameTooLarge + } + let length = UInt32(data.count) + var frame = Data([ + UInt8((length >> 24) & 0xff), + UInt8((length >> 16) & 0xff), + UInt8((length >> 8) & 0xff), + UInt8(length & 0xff), + ]) + frame.append(data) + try writeAll(frame, to: socket, deadline: Date().addingTimeInterval(timeout)) + } + + private func readExactly( + _ count: Int, from socket: SOCKET, deadline: Date? = nil + ) throws -> Data { + var result = Data() + result.reserveCapacity(count) + while result.count < count { + if let deadline { + try setSocketTimeout(socket, until: deadline) + } else { + try clearSocketTimeout(socket) + } + let requested = count - result.count + var buffer = [UInt8](repeating: 0, count: requested) + let received = buffer.withUnsafeMutableBytes { + recv(socket, $0.baseAddress, Int32(requested), 0) + } + guard received > 0 else { + if WSAGetLastError() == WSAETIMEDOUT { throw WindowsRemoteBridgeError.invalidFrame } + throw WindowsRemoteBridgeError.invalidFrame + } + result.append(contentsOf: buffer.prefix(Int(received))) + } + return result + } + + private func writeAll( + _ data: Data, to socket: SOCKET, deadline: Date + ) throws { + var offset = 0 + while offset < data.count { + try setSocketTimeout(socket, until: deadline, sending: true) + let sent = data.withUnsafeBytes { + send(socket, $0.baseAddress!.advanced(by: offset), Int32(data.count - offset), 0) + } + guard sent > 0 else { throw WindowsRemoteBridgeError.backendUnavailable } + offset += Int(sent) + } + } + + private func setSocketTimeout( + _ socket: SOCKET, until deadline: Date, sending: Bool = false + ) throws { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { throw WindowsRemoteBridgeError.invalidFrame } + var milliseconds = DWORD(min(Double(DWORD.max), max(1, (remaining * 1_000).rounded(.up)))) + let option = sending ? SO_SNDTIMEO : SO_RCVTIMEO + let result = withUnsafePointer(to: &milliseconds) { + setsockopt(socket, SOL_SOCKET, option, $0, Int32(MemoryLayout.size)) + } + guard result == 0 else { throw WindowsRemoteBridgeError.invalidFrame } + } + + private func clearSocketTimeout(_ socket: SOCKET) throws { + var milliseconds: DWORD = 0 + let result = withUnsafePointer(to: &milliseconds) { + setsockopt( + socket, SOL_SOCKET, SO_RCVTIMEO, $0, Int32(MemoryLayout.size)) + } + guard result == 0 else { throw WindowsRemoteBridgeError.invalidFrame } + } + + private func blocking( + _ operation: @escaping @Sendable () async throws -> Result + ) throws -> Result { + let semaphore = DispatchSemaphore(value: 0) + let result = LockedResult() + Task { + do { + result.store(.success(try await operation())) + } catch { + result.store(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + return try result.value() + } + + private func constantTimeEqual(_ lhs: String, _ rhs: String) -> Bool { + guard RemoteBridgeWireState.isCapability(lhs), RemoteBridgeWireState.isCapability(rhs) + else { return false } + var difference: UInt8 = 0 + for (left, right) in zip(lhs.utf8, rhs.utf8) { + difference |= left ^ right + } + return difference == 0 + } + + private func withWideString( + _ value: String, _ body: (UnsafePointer) throws -> Result + ) rethrows -> Result { + var buffer = Array(value.utf16) + buffer.append(0) + return try buffer.withUnsafeBufferPointer { try body($0.baseAddress!) } + } + + private func startWinsock() { + struct State { + static let lock = NSLock() + static nonisolated(unsafe) var started = false + } + State.lock.lock() + defer { State.lock.unlock() } + guard !State.started else { return } + var data = WSADATA() + guard WSAStartup(WORD(0x202), &data) == 0 else { return } + State.started = true + } + + private func bind(requestedPort: UInt16) throws -> (socket: SOCKET, port: UInt16) { + let socket = socket(AF_INET, Int32(SOCK_STREAM), Int32(IPPROTO_TCP.rawValue)) + guard socket != INVALID_SOCKET else { + throw WindowsRemoteBridgeError.listenerUnavailable + } + var address = sockaddr_in() + address.sin_family = ADDRESS_FAMILY(AF_INET) + address.sin_port = htons(requestedPort) + address.sin_addr.S_un.S_addr = UInt32(0x0100_007F) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + WinSDK.bind(socket, $0, Int32(MemoryLayout.size)) + } + } + guard bound == 0, listen(socket, 32) == 0 else { + _ = closesocket(socket) + throw WindowsRemoteBridgeError.listenerUnavailable + } + var length = Int32(MemoryLayout.size) + var actual = sockaddr_in() + let named = withUnsafeMutablePointer(to: &actual) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(socket, $0, &length) + } + } + guard named == 0 else { + _ = closesocket(socket) + throw WindowsRemoteBridgeError.listenerUnavailable + } + return (socket, ntohs(actual.sin_port)) + } +#endif diff --git a/GraphcodeKit/Sources/Platform/PlatformContracts.swift b/GraphcodeKit/Sources/Platform/PlatformContracts.swift new file mode 100644 index 00000000..be81a2bd --- /dev/null +++ b/GraphcodeKit/Sources/Platform/PlatformContracts.swift @@ -0,0 +1,152 @@ +import Foundation + +public protocol PlatformPaths: Sendable { + var supportDirectory: URL { get } + var binDirectory: URL { get } + var hooksDirectory: URL { get } + var sessionsDirectory: URL { get } + + func canonicalProjectPath(_ path: String) throws -> String + func persistenceKey(forProjectPath path: String) -> String +} +public struct ProcessRequest: Equatable, Sendable { + public var executable: URL + public var arguments: [String] + public var workingDirectory: URL? + public var environment: [String: String] + public var standardInput: Data? + + public init( + executable: URL, + arguments: [String] = [], + workingDirectory: URL? = nil, + environment: [String: String] = [:], + standardInput: Data? = nil + ) { + self.executable = executable + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + self.standardInput = standardInput + } +} +public struct ProcessResult: Equatable, Sendable { + public var exitCode: Int32 + public var standardOutput: Data + public var standardError: Data + + public init(exitCode: Int32, standardOutput: Data, standardError: Data) { + self.exitCode = exitCode + self.standardOutput = standardOutput + self.standardError = standardError + } +} +public protocol ProcessRunner: Sendable { + func run(_ request: ProcessRequest, timeout: Duration?) async throws -> ProcessResult +} +public enum ShellKind: String, Codable, Equatable, Sendable { + case direct + case commandPrompt + case powerShell + case posix + case wsl +} +public struct ShellInvocation: Equatable, Sendable { + public var kind: ShellKind + public var request: ProcessRequest + + public init(kind: ShellKind, request: ProcessRequest) { + self.kind = kind + self.request = request + } +} +public protocol ShellStrategy: Sendable { + func invocation( + executable: URL, + arguments: [String], + workingDirectory: URL?, + environment: [String: String] + ) throws -> ShellInvocation +} +public enum ShellStrategyError: Error, Equatable, LocalizedError, Sendable { + case commandContainsLineBreak + + public var errorDescription: String? { + "Command paths and arguments cannot contain carriage returns or line feeds." + } +} +public protocol SessionService: Sendable { + func ensureSession(_ node: LoopNode, projectPath: String?) async throws + func terminateSession(_ node: LoopNode, projectPath: String?) async throws + func send(_ text: String, to node: LoopNode, projectPath: String?) async throws -> Bool + func presence(of node: LoopNode, projectPath: String?) async throws -> PresenceReading + func usage(of node: LoopNode, projectPath: String?) async throws -> UsageSample? + func activity(of node: LoopNode, projectPath: String?) async throws -> String? +} +public enum StartupStatus: Equatable, Sendable { + case notInstalled + case stopped + case running +} +public protocol StartupManager: Sendable { + func installAndStart() async throws + func stopAndUninstall() async throws + func status() async throws -> StartupStatus +} +public struct RemoteBridgeState: Codable, Equatable, Sendable { + public static let currentSchemaVersion = 1 + + public var schemaVersion: Int + public var instanceID: UUID + public var generation: UInt64 + public var remotePort: UInt16 + public var capability: String + public var issuedAt: Date + public var expiresAt: Date + + public init( + schemaVersion: Int = RemoteBridgeState.currentSchemaVersion, + instanceID: UUID, + generation: UInt64, + remotePort: UInt16, + capability: String, + issuedAt: Date, + expiresAt: Date + ) { + self.schemaVersion = schemaVersion + self.instanceID = instanceID + self.generation = generation + self.remotePort = remotePort + self.capability = capability + self.issuedAt = issuedAt + self.expiresAt = expiresAt + } + + @discardableResult + public func validated() throws -> Self { + guard schemaVersion == Self.currentSchemaVersion else { + throw ValidationError.unsupportedSchema(schemaVersion) + } + guard generation > 0 else { throw ValidationError.invalidGeneration } + guard remotePort > 0 else { throw ValidationError.invalidPort } + guard capability.utf8.count >= 32 else { throw ValidationError.capabilityTooShort } + guard expiresAt > issuedAt else { throw ValidationError.invalidExpiry } + return self + } + + public enum ValidationError: Error, Equatable { + case unsupportedSchema(Int) + case invalidGeneration + case invalidPort + case capabilityTooShort + case invalidExpiry + } +} +public protocol RemoteBridge: Sendable { + func ensureForwarding(authority: String) async throws -> RemoteBridgeState + func stopForwarding(authority: String) async throws +} + +protocol WindowsRemoteBridgeService: Sendable { + func ensureForwarding(authority: WindowsSSHAuthority) async throws -> RemoteBridgeState +} diff --git a/GraphcodeKit/Sources/Platform/PlatformPaths.swift b/GraphcodeKit/Sources/Platform/PlatformPaths.swift new file mode 100644 index 00000000..49b54ea7 --- /dev/null +++ b/GraphcodeKit/Sources/Platform/PlatformPaths.swift @@ -0,0 +1,373 @@ +import Foundation + +#if os(Windows) + import WinSDK +#endif + +public enum PlatformPathError: Error, Equatable, LocalizedError, Sendable { + case emptyPath + case notAbsolute(String) + case remotePath(String) + case rootPath(String) + + public var errorDescription: String? { + switch self { + case .emptyPath: + return "The project path is empty." + case .notAbsolute(let path): + return "The project path is not absolute: \(path)" + case .remotePath(let path): + return "The project path is remote, not local: \(path)" + case .rootPath(let path): + return "The filesystem root is not a project: \(path)" + } + } +} +public struct WindowsPlatformPaths: PlatformPaths { + public let supportDirectory: URL + public let binDirectory: URL + public let hooksDirectory: URL + public let sessionsDirectory: URL + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + let root = SupportDirectory.url(environment: environment, homeDirectory: homeDirectory) + supportDirectory = root + binDirectory = root.appendingPathComponent("bin", isDirectory: true) + hooksDirectory = root.appendingPathComponent("hooks", isDirectory: true) + sessionsDirectory = root.appendingPathComponent("sessions", isDirectory: true) + } + + public func canonicalProjectPath(_ path: String) throws -> String { + try PlatformPathAlgorithms.canonicalProjectPath(path, windows: true) + } + + public func persistenceKey(forProjectPath path: String) -> String { + let canonical = (try? canonicalProjectPath(path)) ?? path + return PlatformPersistenceKey.make(for: canonical) + } +} +public struct DarwinPlatformPaths: PlatformPaths { + public let supportDirectory: URL + public let binDirectory: URL + public let hooksDirectory: URL + public let sessionsDirectory: URL + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + ) { + let root = SupportDirectory.url(environment: environment, homeDirectory: homeDirectory) + supportDirectory = root + binDirectory = root.appendingPathComponent("bin", isDirectory: true) + hooksDirectory = root.appendingPathComponent("hooks", isDirectory: true) + sessionsDirectory = root.appendingPathComponent("sessions", isDirectory: true) + } + + public func canonicalProjectPath(_ path: String) throws -> String { + try PlatformPathAlgorithms.canonicalProjectPath(path, windows: false) + } + + public func persistenceKey(forProjectPath path: String) -> String { + let canonical = (try? canonicalProjectPath(path)) ?? path + return PlatformPersistenceKey.make(for: canonical) + } +} + +#if os(Windows) + public typealias DefaultPlatformPaths = WindowsPlatformPaths +#else + public typealias DefaultPlatformPaths = DarwinPlatformPaths +#endif + +public enum CurrentPlatformPaths { + public static var value: any PlatformPaths { + #if os(Windows) + WindowsPlatformPaths() + #else + DarwinPlatformPaths() + #endif + } +} +private enum PlatformPathAlgorithms { + static func canonicalProjectPath(_ path: String, windows: Bool) throws -> String { + guard !path.isEmpty else { throw PlatformPathError.emptyPath } + guard !looksLikeRemotePath(path) else { throw PlatformPathError.remotePath(path) } + guard windows ? isWindowsAbsolute(path) : path.hasPrefix("/") else { + throw PlatformPathError.notAbsolute(path) + } + guard !isRootPath(path, windows: windows) else { + throw PlatformPathError.rootPath(path) + } + // Lexically, before any symlink resolution. `standardizedFileURL` resolves `/tmp` + // first, so `/tmp/..` lands on `/private` — a real directory that is not the root + // and would therefore be accepted, when what was named reduces to `/`. + if !windows, RemoteProjectLocation.normalizedPath(path) == "/" { + throw PlatformPathError.rootPath(path) + } + + let canonical: String + if windows { + let normalized = canonicalWindowsPath(path) + guard !isRootPath(normalized, windows: true) else { + throw PlatformPathError.rootPath(path) + } + let resolved = canonicalWindowsPath(resolveWindowsFinalPath(normalized)) + guard !isRootPath(resolved, windows: true) else { + throw PlatformPathError.rootPath(path) + } + let urlPath = URL(fileURLWithPath: resolved).standardizedFileURL.path + canonical = + resolved.hasPrefix("\\\\") && !urlPath.hasPrefix("//") + ? "/" + urlPath + : urlPath + } else { + canonical = + URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + } + guard !isRootPath(canonical, windows: windows) else { + throw PlatformPathError.rootPath(path) + } + return canonical + } + + private static func looksLikeRemotePath(_ path: String) -> Bool { + path.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression) != nil + } + + private static func isWindowsAbsolute(_ path: String) -> Bool { + if path.hasPrefix("\\\\") || path.hasPrefix("//") { + return true + } + guard path.count >= 3 else { return false } + let characters = Array(path) + return characters[1] == ":" && (characters[2] == "\\" || characters[2] == "/") + } + + private static func canonicalWindowsPath(_ path: String) -> String { + let normalized = normalizeWindowsFinalPath(path) + if normalized.hasPrefix("\\\\") { + let components = normalized.split(separator: "\\", omittingEmptySubsequences: true) + guard components.count >= 2 else { return normalized } + let root = components.prefix(2).map(String.init) + let tail = collapseWindowsComponents(components.dropFirst(2)) + return "\\\\" + (root + tail).joined(separator: "\\") + } + + let drive = String(normalized.prefix(2)) + let tail = normalized.dropFirst(2) + let components = tail.split(separator: "\\", omittingEmptySubsequences: true) + let collapsed = collapseWindowsComponents(components) + return drive + "\\" + collapsed.joined(separator: "\\") + } + + private static func resolveWindowsFinalPath(_ path: String) -> String { + #if os(Windows) + var widePath = Array(path.utf16) + widePath.append(0) + let handle = widePath.withUnsafeBufferPointer { + CreateFileW( + $0.baseAddress, + DWORD(FILE_READ_ATTRIBUTES), + DWORD(FILE_SHARE_READ) | DWORD(FILE_SHARE_WRITE) | DWORD(FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), + nil) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + return path + } + defer { _ = CloseHandle(handle) } + + var buffer = [WCHAR](repeating: 0, count: 260) + while true { + let length = buffer.withUnsafeMutableBufferPointer { + GetFinalPathNameByHandleW( + handle, + $0.baseAddress, + DWORD($0.count), + DWORD(VOLUME_NAME_DOS)) + } + guard length > 0 else { return path } + if Int(length) < buffer.count { + let resolved = String( + decoding: buffer.prefix(Int(length)), + as: UTF16.self) + return normalizeWindowsFinalPath(resolved) + } + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + } + #else + return path + #endif + } + + private static func normalizeWindowsFinalPath(_ path: String) -> String { + let normalized = path.replacingOccurrences(of: "/", with: "\\") + let uncPrefix = "\\\\?\\UNC\\" + if normalized.range(of: uncPrefix, options: [.caseInsensitive, .anchored]) != nil { + return "\\\\" + String(normalized.dropFirst(uncPrefix.count)) + } + let devicePrefix = "\\\\?\\" + if normalized.range(of: devicePrefix, options: [.caseInsensitive, .anchored]) != nil { + return String(normalized.dropFirst(devicePrefix.count)) + } + return normalized + } + + private static func collapseWindowsComponents( + _ components: some Collection + ) -> [String] { + var collapsed: [String] = [] + for component in components { + switch component { + case ".": + continue + case "..": + if !collapsed.isEmpty { collapsed.removeLast() } + default: + collapsed.append(String(component)) + } + } + return collapsed + } + + private static func isRootPath(_ path: String, windows: Bool) -> Bool { + if !windows { + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + return normalized == "/" + } + + let normalized = canonicalWindowsPath(path) + if normalized.range( + of: #"^[A-Za-z]:\\*$"#, + options: .regularExpression) != nil + { + return true + } + if normalized == "\\" || normalized == "\\\\" { + return true + } + + let components = normalized.split(separator: "\\", omittingEmptySubsequences: true) + return normalized.hasPrefix("\\\\") && components.count <= 2 + } +} +private enum PlatformPersistenceKey { + static func make(for path: String) -> String { + "v1-" + GraphcodeSHA256.hex(Data(path.utf8)) + } +} +/// Small dependency-free SHA-256 used for stable, privacy-preserving path +/// identities on every supported platform. +internal enum GraphcodeSHA256 { + private static let initial: [UInt32] = [ + 0x6a09_e667, 0xbb67_ae85, 0x3c6e_f372, 0xa54f_f53a, + 0x510e_527f, 0x9b05_688c, 0x1f83_d9ab, 0x5be0_cd19, + ] + + private static let constants: [UInt32] = [ + 0x428a_2f98, 0x7137_4491, 0xb5c0_fbcf, 0xe9b5_dba5, 0x3956_c25b, 0x59f1_11f1, + 0x923f_82a4, 0xab1c_5ed5, 0xd807_aa98, 0x1283_5b01, 0x2431_85be, 0x550c_7dc3, + 0x72be_5d74, 0x80de_b1fe, 0x9bdc_06a7, 0xc19b_f174, 0xe49b_69c1, 0xefbe_4786, + 0x0fc1_9dc6, 0x240c_a1cc, 0x2de9_2c6f, 0x4a74_84aa, 0x5cb0_a9dc, 0x76f9_88da, + 0x983e_5152, 0xa831_c66d, 0xb003_27c8, 0xbf59_7fc7, 0xc6e0_0bf3, 0xd5a7_9147, + 0x06ca_6351, 0x1429_2967, 0x27b7_0a85, 0x2e1b_2138, 0x4d2c_6dfc, 0x5338_0d13, + 0x650a_7354, 0x766a_0abb, 0x81c2_c92e, 0x9272_2c85, 0xa2bf_e8a1, 0xa81a_664b, + 0xc24b_8b70, 0xc76c_51a3, 0xd192_e819, 0xd699_0624, 0xf40e_3585, 0x106a_a070, + 0x19a4_c116, 0x1e37_6c08, 0x2748_774c, 0x34b0_bcb5, 0x391c_0cb3, 0x4ed8_aa4a, + 0x5b9c_ca4f, 0x682e_6ff3, 0x748f_82ee, 0x78a5_636f, 0x84c8_7814, 0x8cc7_0208, + 0x90be_fffa, 0xa450_6ceb, 0xbef9_a3f7, 0xc671_78f2, + ] + + static func hex(_ data: Data) -> String { + var message = Array(data) + let bitLength = UInt64(message.count) * 8 + message.append(0x80) + while message.count % 64 != 56 { + message.append(0) + } + message.append(contentsOf: [ + UInt8(truncatingIfNeeded: bitLength >> 56), + UInt8(truncatingIfNeeded: bitLength >> 48), + UInt8(truncatingIfNeeded: bitLength >> 40), + UInt8(truncatingIfNeeded: bitLength >> 32), + UInt8(truncatingIfNeeded: bitLength >> 24), + UInt8(truncatingIfNeeded: bitLength >> 16), + UInt8(truncatingIfNeeded: bitLength >> 8), + UInt8(truncatingIfNeeded: bitLength), + ]) + + var hash = initial + for chunkStart in stride(from: 0, to: message.count, by: 64) { + var schedule = [UInt32](repeating: 0, count: 64) + for index in 0..<16 { + let offset = chunkStart + index * 4 + schedule[index] = + UInt32(message[offset]) << 24 + | UInt32(message[offset + 1]) << 16 + | UInt32(message[offset + 2]) << 8 + | UInt32(message[offset + 3]) + } + for index in 16..<64 { + let s0 = + rotateRight(schedule[index - 15], by: 7) + ^ rotateRight(schedule[index - 15], by: 18) + ^ (schedule[index - 15] >> 3) + let s1 = + rotateRight(schedule[index - 2], by: 17) + ^ rotateRight(schedule[index - 2], by: 19) + ^ (schedule[index - 2] >> 10) + schedule[index] = schedule[index - 16] &+ s0 &+ schedule[index - 7] &+ s1 + } + + var working = hash + for index in 0..<64 { + let s1 = + rotateRight(working[4], by: 6) + ^ rotateRight(working[4], by: 11) + ^ rotateRight(working[4], by: 25) + let choice = (working[4] & working[5]) ^ (~working[4] & working[6]) + let temporary1 = working[7] &+ s1 &+ choice &+ constants[index] &+ schedule[index] + let s0 = + rotateRight(working[0], by: 2) + ^ rotateRight(working[0], by: 13) + ^ rotateRight(working[0], by: 22) + let majority = + (working[0] & working[1]) + ^ (working[0] & working[2]) + ^ (working[1] & working[2]) + let temporary2 = s0 &+ majority + + working[7] = working[6] + working[6] = working[5] + working[5] = working[4] + working[4] = working[3] &+ temporary1 + working[3] = working[2] + working[2] = working[1] + working[1] = working[0] + working[0] = temporary1 &+ temporary2 + } + for index in 0..<8 { + hash[index] = hash[index] &+ working[index] + } + } + + return hash.map { word in + let hex = String(word, radix: 16) + return String(repeating: "0", count: max(0, 8 - hex.count)) + hex + }.joined() + } + + private static func rotateRight(_ value: UInt32, by amount: UInt32) -> UInt32 { + (value >> amount) | (value << (32 - amount)) + } +} diff --git a/GraphcodeKit/Sources/Platform/ProcessRunner.swift b/GraphcodeKit/Sources/Platform/ProcessRunner.swift new file mode 100644 index 00000000..37ebbfa1 --- /dev/null +++ b/GraphcodeKit/Sources/Platform/ProcessRunner.swift @@ -0,0 +1,952 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#endif + +#if os(Windows) + import WinSDK +#endif + +public enum ProcessRunnerError: Error, Equatable, LocalizedError, Sendable { + case emptyExecutable + case launchFailed(String) + case timedOut + case cancelled + + public var errorDescription: String? { + switch self { + case .emptyExecutable: + return "No executable was provided." + case .launchFailed(let message): + return "The process could not be launched: \(message)" + case .timedOut: + return "The process exceeded its timeout." + case .cancelled: + return "The process was cancelled." + } + } +} +public struct FoundationProcessRunner: ProcessRunner { + private let beforeStart: (@Sendable () async -> Void)? + + public init() { + beforeStart = nil + } + + init(beforeStart: @escaping @Sendable () async -> Void) { + self.beforeStart = beforeStart + } + + public func run(_ request: ProcessRequest, timeout: Duration? = nil) async throws -> ProcessResult + { + guard !request.executable.path.isEmpty else { + throw ProcessRunnerError.emptyExecutable + } + + let execution = ProcessExecution(request: request) + let timeoutTask = timeout.map { duration in + Task { + do { + try await Task.sleep(for: duration) + execution.timeout() + } catch { + // The timeout task is cancelled when the process finishes. + } + } + } + defer { timeoutTask?.cancel() } + + return try await withTaskCancellationHandler { + if let beforeStart { + await beforeStart() + } + return try await execution.start() + } onCancel: { + execution.cancel() + } + } +} +public typealias DefaultProcessRunner = FoundationProcessRunner +public typealias WindowsProcessRunner = FoundationProcessRunner +private final class ProcessExecution: @unchecked Sendable { + private let request: ProcessRequest + private let lock = NSLock() + private var process: PlatformProcess? + private var completion: CheckedContinuation? + private var outcome: Result? + private let treeController = ProcessTreeController() + private var processStarted = false + + init(request: ProcessRequest) { + self.request = request + } + + func start() async throws -> ProcessResult { + try await withCheckedThrowingContinuation { continuation in + lock.lock() + if let outcome { + lock.unlock() + switch outcome { + case .success(let result): + continuation.resume(returning: result) + case .failure(let error): + continuation.resume(throwing: error) + } + return + } + completion = continuation + lock.unlock() + + var environment = ProcessInfo.processInfo.environment + for (key, value) in request.environment { + environment[key] = value + } + var launchRequest = request + launchRequest.environment = environment + launchRequest.executable = Self.resolveExecutable( + request.executable, + environment: environment) + + lock.lock() + if outcome != nil { + lock.unlock() + return + } + if Task.isCancelled { + lock.unlock() + finish(error: .cancelled) + return + } + + let process: PlatformProcess + do { + process = try treeController.launch(launchRequest) + self.process = process + processStarted = true + } catch let error as ProcessRunnerError { + lock.unlock() + finish(error: error) + return + } catch { + lock.unlock() + finish(error: .launchFailed(String(describing: error))) + return + } + lock.unlock() + + if let input = request.standardInput, let inputPipe = process.standardInput { + DispatchQueue.global(qos: .utility).async { + inputPipe.write(input) + } + } + + let group = DispatchGroup() + let collected = CollectedOutput() + let termination = TerminationStatus() + + group.enter() + DispatchQueue.global(qos: .utility).async { + collected.stdout = process.standardOutput.readDataToEndOfFile() + group.leave() + } + + group.enter() + DispatchQueue.global(qos: .utility).async { + collected.stderr = process.standardError.readDataToEndOfFile() + group.leave() + } + + group.enter() + DispatchQueue.global(qos: .utility).async { + termination.code = process.waitUntilExit() + self.treeController.rootDidExit() + group.leave() + } + + group.notify(queue: .global(qos: .utility)) { [weak self] in + guard let self else { return } + let result = ProcessResult( + exitCode: termination.code, + standardOutput: collected.stdout, + standardError: collected.stderr) + self.finish(result: result) + } + } + } + + func timeout() { + finish(error: .timedOut) + } + + func cancel() { + finish(error: .cancelled) + } + + private func finish(result: ProcessResult) { + lock.lock() + if case .failure(let error) = outcome { + let continuation = completion + completion = nil + lock.unlock() + treeController.close() + continuation?.resume(throwing: error) + return + } + guard outcome == nil else { + lock.unlock() + return + } + outcome = .success(result) + let continuation = completion + completion = nil + lock.unlock() + treeController.close() + continuation?.resume(returning: result) + } + + private func finish(error: ProcessRunnerError) { + lock.lock() + guard outcome == nil else { + lock.unlock() + return + } + outcome = .failure(error) + let processStarted = processStarted + let continuation: CheckedContinuation? + if processStarted { + continuation = nil + } else { + continuation = completion + completion = nil + } + lock.unlock() + if processStarted { + treeController.terminate() + } else { + treeController.close() + continuation?.resume(throwing: error) + } + } + + private static func resolveExecutable( + _ executable: URL, + environment: [String: String] + ) -> URL { + let path = executable.path + guard !path.contains("/"), !path.contains("\\"), + !path.contains(":"), + let pathValue = environment.first(where: { + $0.key.caseInsensitiveCompare("PATH") == .orderedSame + })?.value + else { + return executable + } + + let candidates = + pathValue + .split(separator: ";", omittingEmptySubsequences: true) + .map(String.init) + .map { URL(fileURLWithPath: $0, isDirectory: true).appendingPathComponent(path) } + if let candidate = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) + { + return candidate + } + return executable + } + + private final class CollectedOutput: @unchecked Sendable { + var stdout = Data() + var stderr = Data() + } + + private final class TerminationStatus: @unchecked Sendable { + var code: Int32 = -1 + } +} +private final class ProcessTreeController: @unchecked Sendable { + private let lock = NSLock() + private var process: PlatformProcess? + #if os(Windows) + private var job: HANDLE? + #elseif canImport(Darwin) + private var processGroupID: pid_t? + #endif + + func launch(_ request: ProcessRequest) throws -> PlatformProcess { + lock.lock() + defer { lock.unlock() } + #if os(Windows) + let job = try makeWindowsJob() + self.job = job + do { + let process = try PlatformProcess.launchWindows(request, job: job) + self.process = process + return process + } catch { + _ = CloseHandle(job) + self.job = nil + throw error + } + #elseif canImport(Darwin) + let launched = try PlatformProcess.launchDarwin(request) + processGroupID = launched.processID + process = launched + return launched + #else + let launched = try PlatformProcess.launchFoundation(request) + process = launched + return launched + #endif + } + + func terminate() { + lock.lock() + #if os(Windows) + if let job { + _ = TerminateJobObject(job, 1) + } else { + process?.terminate() + } + #elseif canImport(Darwin) + if let processGroupID { + _ = kill(-processGroupID, SIGTERM) + _ = kill(-processGroupID, SIGKILL) + } else { + process?.terminate() + } + #else + process?.terminate() + #endif + lock.unlock() + } + + func rootDidExit() { + #if os(Windows) + lock.lock() + if let job { + _ = TerminateJobObject(job, 0) + _ = CloseHandle(job) + self.job = nil + } + lock.unlock() + #elseif canImport(Darwin) + lock.lock() + if let processGroupID { + Self.terminateDarwinProcessGroup(processGroupID) + } + lock.unlock() + #endif + } + + func close() { + lock.lock() + let process = self.process + self.process = nil + #if os(Windows) + let job = self.job + self.job = nil + #elseif canImport(Darwin) + let processGroupID = self.processGroupID + self.processGroupID = nil + #endif + #if canImport(Darwin) + if let processGroupID { + Self.terminateDarwinProcessGroup(processGroupID) + } + #endif + process?.close() + #if os(Windows) + if let job { + _ = CloseHandle(job) + } + #endif + lock.unlock() + } + + #if canImport(Darwin) + private static func terminateDarwinProcessGroup(_ processGroupID: pid_t) { + guard processGroupID > 0 else { return } + _ = kill(-processGroupID, SIGTERM) + _ = kill(-processGroupID, SIGKILL) + } + #endif + + #if os(Windows) + private func makeWindowsJob() throws -> HANDLE { + guard let job = CreateJobObjectW(nil, nil) else { + throw ProcessRunnerError.launchFailed("CreateJobObjectW failed") + } + var limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + limits.BasicLimitInformation.LimitFlags = DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + let configured = withUnsafeMutablePointer(to: &limits) { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + $0, + DWORD(MemoryLayout.size)) + } + guard configured else { + _ = CloseHandle(job) + throw ProcessRunnerError.launchFailed("SetInformationJobObject failed") + } + return job + } + #endif +} +private final class PlatformProcess: @unchecked Sendable { + let standardOutput: ProcessPipe + let standardError: ProcessPipe + let standardInput: ProcessPipe? + #if os(Windows) + private let processHandle: HANDLE + #elseif canImport(Darwin) + let processID: pid_t + #else + private let foundationProcess: Process + #endif + + #if os(Windows) + private init( + standardOutput: ProcessPipe, + standardError: ProcessPipe, + standardInput: ProcessPipe?, + processHandle: HANDLE + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.standardInput = standardInput + self.processHandle = processHandle + } + #elseif canImport(Darwin) + private init( + standardOutput: ProcessPipe, + standardError: ProcessPipe, + standardInput: ProcessPipe?, + processID: pid_t + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.standardInput = standardInput + self.processID = processID + } + #else + private init( + standardOutput: ProcessPipe, + standardError: ProcessPipe, + standardInput: ProcessPipe?, + foundationProcess: Process + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.standardInput = standardInput + self.foundationProcess = foundationProcess + } + #endif + + func waitUntilExit() -> Int32 { + #if os(Windows) + _ = WaitForSingleObject(processHandle, INFINITE) + var code: DWORD = 1 + _ = GetExitCodeProcess(processHandle, &code) + return Int32(bitPattern: code) + #elseif canImport(Darwin) + var status: Int32 = 0 + while waitpid(processID, &status, 0) == -1, errno == EINTR {} + if status & 0x7f == 0 { + return (status >> 8) & 0xff + } + let terminatingSignal = status & 0x7f + if terminatingSignal != 0, terminatingSignal != 0x7f { + return 128 + terminatingSignal + } + return 1 + #else + foundationProcess.waitUntilExit() + return foundationProcess.terminationStatus + #endif + } + + func terminate() { + #if os(Windows) + _ = TerminateProcess(processHandle, 1) + #elseif canImport(Darwin) + _ = kill(processID, SIGKILL) + #else + foundationProcess.terminate() + #endif + } + + func close() { + standardOutput.close() + standardError.close() + standardInput?.close() + #if os(Windows) + _ = CloseHandle(processHandle) + #endif + } + + #if os(Windows) + static func launchWindows(_ request: ProcessRequest, job: HANDLE) throws -> PlatformProcess { + var security = SECURITY_ATTRIBUTES() + security.nLength = DWORD(MemoryLayout.size) + security.bInheritHandle = true + + var stdinRead: HANDLE? + var stdinWrite: HANDLE? + var stdoutRead: HANDLE? + var stdoutWrite: HANDLE? + var stderrRead: HANDLE? + var stderrWrite: HANDLE? + guard CreatePipe(&stdinRead, &stdinWrite, &security, 0), + CreatePipe(&stdoutRead, &stdoutWrite, &security, 0), + CreatePipe(&stderrRead, &stderrWrite, &security, 0), + let stdinRead, + let stdinWrite, + let stdoutRead, + let stdoutWrite, + let stderrRead, + let stderrWrite + else { + closeWindowsHandles(stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite) + throw ProcessRunnerError.launchFailed("CreatePipe failed") + } + + func closeParentHandles() { + _ = CloseHandle(stdinRead) + _ = CloseHandle(stdoutWrite) + _ = CloseHandle(stderrWrite) + } + + guard SetHandleInformation(stdinWrite, DWORD(HANDLE_FLAG_INHERIT), 0), + SetHandleInformation(stdoutRead, DWORD(HANDLE_FLAG_INHERIT), 0), + SetHandleInformation(stderrRead, DWORD(HANDLE_FLAG_INHERIT), 0) + else { + closeWindowsHandles(stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite) + throw ProcessRunnerError.launchFailed("SetHandleInformation failed") + } + + var startup = STARTUPINFOEXW() + startup.StartupInfo.cb = DWORD(MemoryLayout.size) + startup.StartupInfo.dwFlags = DWORD(STARTF_USESTDHANDLES) + startup.StartupInfo.hStdInput = stdinRead + startup.StartupInfo.hStdOutput = stdoutWrite + startup.StartupInfo.hStdError = stderrWrite + var attributeSize: SIZE_T = 0 + _ = InitializeProcThreadAttributeList(nil, 1, 0, &attributeSize) + guard attributeSize > 0 else { + closeWindowsHandles(stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite) + throw ProcessRunnerError.launchFailed("InitializeProcThreadAttributeList sizing failed") + } + let attributeMemory = UnsafeMutableRawPointer.allocate( + byteCount: Int(attributeSize), + alignment: MemoryLayout.alignment) + let attributeList = OpaquePointer(attributeMemory) + guard InitializeProcThreadAttributeList(attributeList, 1, 0, &attributeSize) else { + attributeMemory.deallocate() + closeWindowsHandles(stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite) + throw ProcessRunnerError.launchFailed("InitializeProcThreadAttributeList failed") + } + defer { + DeleteProcThreadAttributeList(attributeList) + attributeMemory.deallocate() + } + var inheritedHandles = [stdinRead, stdoutWrite, stderrWrite] + let handlesConfigured = inheritedHandles.withUnsafeMutableBufferPointer { handles in + UpdateProcThreadAttribute( + attributeList, + 0, + DWORD_PTR(0x0002_0002), + handles.baseAddress, + SIZE_T(MemoryLayout.stride * handles.count), + nil, + nil) + } + guard handlesConfigured else { + closeWindowsHandles(stdinRead, stdinWrite, stdoutRead, stdoutWrite, stderrRead, stderrWrite) + throw ProcessRunnerError.launchFailed("UpdateProcThreadAttribute failed") + } + startup.lpAttributeList = attributeList + var processInfo = PROCESS_INFORMATION() + var application = wideString(request.executable.path) + var commandLine = wideString(windowsCommandLine(request)) + let workingDirectory = request.workingDirectory.map { wideString($0.path) } + var environment = wideEnvironment(request.environment) + let flags = DWORD( + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT) + + func createProcess(_ workingDirectory: UnsafeMutablePointer?) -> Bool { + application.withUnsafeMutableBufferPointer { application in + commandLine.withUnsafeMutableBufferPointer { commandLine in + environment.withUnsafeMutableBufferPointer { environment in + CreateProcessW( + application.baseAddress, + commandLine.baseAddress, + nil, + nil, + true, + flags, + UnsafeMutableRawPointer(environment.baseAddress), + workingDirectory, + &startup.StartupInfo, + &processInfo) + } + } + } + } + let created: Bool + if var workingDirectory { + created = workingDirectory.withUnsafeMutableBufferPointer { + createProcess($0.baseAddress) + } + } else { + created = createProcess(nil) + } + guard created else { + closeParentHandles() + _ = CloseHandle(stdinWrite) + _ = CloseHandle(stdoutRead) + _ = CloseHandle(stderrRead) + _ = CloseHandle(processInfo.hThread) + _ = CloseHandle(processInfo.hProcess) + throw ProcessRunnerError.launchFailed("CreateProcessW failed") + } + + guard AssignProcessToJobObject(job, processInfo.hProcess) else { + _ = TerminateProcess(processInfo.hProcess, 1) + _ = CloseHandle(processInfo.hThread) + _ = CloseHandle(processInfo.hProcess) + closeParentHandles() + _ = CloseHandle(stdinWrite) + _ = CloseHandle(stdoutRead) + _ = CloseHandle(stderrRead) + throw ProcessRunnerError.launchFailed("AssignProcessToJobObject failed") + } + guard ResumeThread(processInfo.hThread) != DWORD.max else { + _ = TerminateProcess(processInfo.hProcess, 1) + _ = CloseHandle(processInfo.hThread) + _ = CloseHandle(processInfo.hProcess) + closeParentHandles() + _ = CloseHandle(stdinWrite) + _ = CloseHandle(stdoutRead) + _ = CloseHandle(stderrRead) + throw ProcessRunnerError.launchFailed("ResumeThread failed") + } + + _ = CloseHandle(processInfo.hThread) + closeParentHandles() + let input: ProcessPipe? + if request.standardInput == nil { + _ = CloseHandle(stdinWrite) + input = nil + } else { + input = ProcessPipe(handle: stdinWrite) + } + let output = ProcessPipe(handle: stdoutRead) + let error = ProcessPipe(handle: stderrRead) + return PlatformProcess( + standardOutput: output, + standardError: error, + standardInput: input, + processHandle: processInfo.hProcess) + } + + private static func wideString(_ value: String) -> [UInt16] { + Array(value.utf16) + [0] + } + + private static func wideEnvironment(_ environment: [String: String]) -> [UInt16] { + environment.keys.sorted().map { "\($0)=\(environment[$0] ?? "")" } + .joined(separator: "\0") + .utf16 + + [0, 0] + } + + private static func quoteWindowsArgument(_ value: String) -> String { + var result = "\"" + var backslashes = 0 + for character in value { + if character == "\\" { + backslashes += 1 + } else if character == "\"" { + result += String(repeating: "\\", count: backslashes * 2 + 1) + result.append(character) + backslashes = 0 + } else { + result += String(repeating: "\\", count: backslashes) + result.append(character) + backslashes = 0 + } + } + result += String(repeating: "\\", count: backslashes * 2) + result.append("\"") + return result + } + + private static func windowsCommandLine(_ request: ProcessRequest) -> String { + let executable = quoteWindowsArgument( + request.executable.path.replacingOccurrences(of: "/", with: "\\")) + guard request.executable.lastPathComponent.lowercased() == "cmd.exe", + let commandIndex = request.arguments.firstIndex(where: { + $0.caseInsensitiveCompare("/c") == .orderedSame + || $0.caseInsensitiveCompare("/k") == .orderedSame + }), + commandIndex + 1 < request.arguments.count + else { + return ([executable] + request.arguments.map(quoteWindowsArgument)) + .joined(separator: " ") + } + + let options = request.arguments[.. PlatformProcess { + let inputPipe = request.standardInput.map { _ in Pipe() } + let outputPipe = Pipe() + let errorPipe = Pipe() + let nullInput = inputPipe == nil ? open("/dev/null", O_RDONLY) : -1 + guard inputPipe != nil || nullInput >= 0 else { + throw ProcessRunnerError.launchFailed("Could not open /dev/null") + } + + var actions: posix_spawn_file_actions_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { + if nullInput >= 0 { _ = Darwin.close(nullInput) } + throw ProcessRunnerError.launchFailed("posix_spawn file actions initialization failed") + } + defer { posix_spawn_file_actions_destroy(&actions) } + + let inputRead = inputPipe?.fileHandleForReading.fileDescriptor ?? nullInput + let inputWrite = inputPipe?.fileHandleForWriting.fileDescriptor + let outputRead = outputPipe.fileHandleForReading.fileDescriptor + let outputWrite = outputPipe.fileHandleForWriting.fileDescriptor + let errorRead = errorPipe.fileHandleForReading.fileDescriptor + let errorWrite = errorPipe.fileHandleForWriting.fileDescriptor + func closePipes() { + try? inputPipe?.fileHandleForReading.close() + try? inputPipe?.fileHandleForWriting.close() + try? outputPipe.fileHandleForReading.close() + try? outputPipe.fileHandleForWriting.close() + try? errorPipe.fileHandleForReading.close() + try? errorPipe.fileHandleForWriting.close() + if nullInput >= 0 { _ = Darwin.close(nullInput) } + } + guard posix_spawn_file_actions_adddup2(&actions, inputRead, STDIN_FILENO) == 0, + posix_spawn_file_actions_adddup2(&actions, outputWrite, STDOUT_FILENO) == 0, + posix_spawn_file_actions_adddup2(&actions, errorWrite, STDERR_FILENO) == 0, + posix_spawn_file_actions_addclose(&actions, inputRead) == 0, + posix_spawn_file_actions_addclose(&actions, outputRead) == 0, + posix_spawn_file_actions_addclose(&actions, outputWrite) == 0, + posix_spawn_file_actions_addclose(&actions, errorRead) == 0, + posix_spawn_file_actions_addclose(&actions, errorWrite) == 0 + else { + closePipes() + throw ProcessRunnerError.launchFailed("posix_spawn file action failed") + } + if let inputWrite { + guard posix_spawn_file_actions_addclose(&actions, inputWrite) == 0 else { + closePipes() + throw ProcessRunnerError.launchFailed("posix_spawn file action failed") + } + } + if let workingDirectory = request.workingDirectory { + let changedDirectory = workingDirectory.path.withCString { + posix_spawn_file_actions_addchdir_np(&actions, $0) + } + guard changedDirectory == 0 else { + closePipes() + throw ProcessRunnerError.launchFailed("posix_spawn working-directory setup failed") + } + } + + var attributes: posix_spawnattr_t? + guard posix_spawnattr_init(&attributes) == 0 else { + closePipes() + throw ProcessRunnerError.launchFailed("posix_spawn attributes initialization failed") + } + defer { posix_spawnattr_destroy(&attributes) } + let flags = Int16(POSIX_SPAWN_SETPGROUP) + guard posix_spawnattr_setflags(&attributes, flags) == 0, + posix_spawnattr_setpgroup(&attributes, 0) == 0 + else { + closePipes() + throw ProcessRunnerError.launchFailed("posix_spawn process-group setup failed") + } + + let arguments = [request.executable.path] + request.arguments + var argv = arguments.map { strdupString($0) } + [nil] + var environment = + request.environment.keys.sorted().map { + strdupString("\($0)=\(request.environment[$0] ?? "")") + } + [nil] + defer { + for pointer in argv { + if let pointer { free(pointer) } + } + for pointer in environment { + if let pointer { free(pointer) } + } + } + + var processID: pid_t = 0 + let result = request.executable.path.withCString { executable in + argv.withUnsafeMutableBufferPointer { argv in + environment.withUnsafeMutableBufferPointer { environment in + posix_spawn( + &processID, + executable, + &actions, + &attributes, + argv.baseAddress, + environment.baseAddress) + } + } + } + guard result == 0 else { + closePipes() + throw ProcessRunnerError.launchFailed(String(cString: strerror(result))) + } + + if nullInput >= 0 { + _ = Darwin.close(nullInput) + } + try? inputPipe?.fileHandleForReading.close() + try? outputPipe.fileHandleForWriting.close() + try? errorPipe.fileHandleForWriting.close() + return PlatformProcess( + standardOutput: ProcessPipe(fileHandle: outputPipe.fileHandleForReading), + standardError: ProcessPipe(fileHandle: errorPipe.fileHandleForReading), + standardInput: inputPipe.map { + ProcessPipe(fileHandle: $0.fileHandleForWriting) + }, + processID: processID) + } + #else + static func launchFoundation(_ request: ProcessRequest) throws -> PlatformProcess { + let process = Process() + process.arguments = request.arguments + process.currentDirectoryURL = request.workingDirectory + process.environment = request.environment + process.executableURL = request.executable + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + let inputPipe = request.standardInput == nil ? nil : Pipe() + process.standardInput = inputPipe ?? FileHandle.nullDevice + do { + try process.run() + } catch { + throw ProcessRunnerError.launchFailed(String(describing: error)) + } + return PlatformProcess( + standardOutput: ProcessPipe(fileHandle: outputPipe.fileHandleForReading), + standardError: ProcessPipe(fileHandle: errorPipe.fileHandleForReading), + standardInput: inputPipe.map { + ProcessPipe(fileHandle: $0.fileHandleForWriting) + }, + foundationProcess: process) + } + #endif + + #if canImport(Darwin) + private static func strdupString(_ value: String) -> UnsafeMutablePointer? { + value.withCString { strdup($0) } + } + #endif +} +private final class ProcessPipe: @unchecked Sendable { + #if os(Windows) + private let lock = NSLock() + private let handle: HANDLE + private var closed = false + + init(handle: HANDLE) { + self.handle = handle + } + + func readDataToEndOfFile() -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1024) + while true { + var count: DWORD = 0 + let succeeded = buffer.withUnsafeMutableBytes { bytes in + ReadFile(handle, bytes.baseAddress, DWORD(bytes.count), &count, nil) + } + if !succeeded || count == 0 { + break + } + data.append(contentsOf: buffer[0.. 0 + else { + break + } + offset += Int(written) + } + } + close() + } + + func close() { + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + lock.unlock() + _ = CloseHandle(handle) + } + #else + private let fileHandle: FileHandle + + init(fileHandle: FileHandle) { + self.fileHandle = fileHandle + } + + func readDataToEndOfFile() -> Data { + let data = fileHandle.readDataToEndOfFile() + close() + return data + } + + func write(_ data: Data) { + do { + try fileHandle.write(contentsOf: data) + } catch { + // The process may have exited before all input was written. + } + close() + } + + func close() { + try? fileHandle.close() + } + #endif +} diff --git a/GraphcodeKit/Sources/Platform/RemoteBridgeWireState.swift b/GraphcodeKit/Sources/Platform/RemoteBridgeWireState.swift new file mode 100644 index 00000000..055f70ad --- /dev/null +++ b/GraphcodeKit/Sources/Platform/RemoteBridgeWireState.swift @@ -0,0 +1,202 @@ +/// The file format exchanged with the POSIX one-shot shim. +/// +/// `RemoteBridgeState` is the frozen Swift-facing contract. This wire record is +/// deliberately separate: it carries the transport host/protocol and uses Unix +/// epoch seconds so a Python process does not need to know Foundation's date +/// encoding. The record is also the boundary where the stricter security checks +/// for a remotely readable state file live. +import Foundation + +public struct RemoteBridgePreviousWireState: Codable, Equatable, Sendable { + public var generation: UInt64 + public var capability: String + public var expiresAt: Double + + public init(generation: UInt64, capability: String, expiresAt: Double) { + self.generation = generation + self.capability = capability + self.expiresAt = expiresAt + } +} +public struct RemoteBridgeWireState: Codable, Equatable, Sendable { + public static let currentProtocolVersion = 1 + public static let loopbackHost = "127.0.0.1" + + public var schemaVersion: Int + public var protocolVersion: Int + public var daemonInstanceID: UUID + public var generation: UInt64 + public var host: String + public var port: UInt16 + public var capability: String + public var issuedAt: Double + public var expiresAt: Double + public var previous: RemoteBridgePreviousWireState? + + public init( + schemaVersion: Int = 1, + protocolVersion: Int = RemoteBridgeWireState.currentProtocolVersion, + daemonInstanceID: UUID, + generation: UInt64, + host: String = RemoteBridgeWireState.loopbackHost, + port: UInt16, + capability: String, + issuedAt: Double, + expiresAt: Double, + previous: RemoteBridgePreviousWireState? = nil + ) { + self.schemaVersion = schemaVersion + self.protocolVersion = protocolVersion + self.daemonInstanceID = daemonInstanceID + self.generation = generation + self.host = host + self.port = port + self.capability = capability + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.previous = previous + } + + public init( + remoteBridgeState state: RemoteBridgeState, + previous: RemoteBridgePreviousWireState? = nil + ) { + self.init( + daemonInstanceID: state.instanceID, + generation: state.generation, + port: state.remotePort, + capability: state.capability, + issuedAt: state.issuedAt.timeIntervalSince1970, + expiresAt: state.expiresAt.timeIntervalSince1970, + previous: previous) + } + + @discardableResult + public func validated(now: Double? = nil) throws -> Self { + guard schemaVersion == 1 else { + throw ValidationError.unsupportedSchema(schemaVersion) + } + guard protocolVersion == Self.currentProtocolVersion else { + throw ValidationError.unsupportedProtocol(protocolVersion) + } + guard generation > 0 else { throw ValidationError.invalidGeneration } + guard host == Self.loopbackHost else { throw ValidationError.invalidHost } + guard port > 0 else { throw ValidationError.invalidPort } + guard Self.isCapability(capability) else { throw ValidationError.invalidCapability } + guard issuedAt.isFinite, expiresAt.isFinite, expiresAt > issuedAt else { + throw ValidationError.invalidExpiry + } + if let now { + guard now.isFinite else { throw ValidationError.invalidExpiry } + guard expiresAt > now else { throw ValidationError.expired } + } + if let previous { + guard previous.generation > 0, + previous.generation < generation, + Self.isCapability(previous.capability), + previous.expiresAt.isFinite, + previous.expiresAt > issuedAt, + previous.expiresAt <= expiresAt + else { + throw ValidationError.invalidPrevious + } + } + return self + } + + public func remoteBridgeState() -> RemoteBridgeState { + RemoteBridgeState( + instanceID: daemonInstanceID, + generation: generation, + remotePort: port, + capability: capability, + issuedAt: Date(timeIntervalSince1970: issuedAt), + expiresAt: Date(timeIntervalSince1970: expiresAt)) + } + + public static func isCapability(_ value: String) -> Bool { + guard value.utf8.count == 64, + value.unicodeScalars.allSatisfy({ $0.value < 128 }) + else { return false } + return value.utf8.allSatisfy { + (0x30...0x39).contains($0) || (0x61...0x66).contains($0) + } + } + + public enum ValidationError: Error, Equatable, Sendable { + case unsupportedSchema(Int) + case unsupportedProtocol(Int) + case invalidGeneration + case invalidHost + case invalidPort + case invalidCapability + case invalidExpiry + case expired + case invalidPrevious + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case protocolVersion = "protocol_version" + case daemonInstanceID = "daemon_instance_id" + case generation + case host + case port + case capability + case issuedAt = "issued_at" + case expiresAt = "expires_at" + case previous + } + + private enum PreviousCodingKeys: String, CodingKey { + case generation + case capability + case expiresAt = "expires_at" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(daemonInstanceID.uuidString, forKey: .daemonInstanceID) + try container.encode(generation, forKey: .generation) + try container.encode(host, forKey: .host) + try container.encode(port, forKey: .port) + try container.encode(capability, forKey: .capability) + try container.encode(issuedAt, forKey: .issuedAt) + try container.encode(expiresAt, forKey: .expiresAt) + if let previous { + var nested = container.nestedContainer(keyedBy: PreviousCodingKeys.self, forKey: .previous) + try nested.encode(previous.generation, forKey: .generation) + try nested.encode(previous.capability, forKey: .capability) + try nested.encode(previous.expiresAt, forKey: .expiresAt) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawID = try container.decode(String.self, forKey: .daemonInstanceID) + guard let daemonInstanceID = UUID(uuidString: rawID) else { + throw ValidationError.invalidGeneration + } + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + protocolVersion = try container.decode(Int.self, forKey: .protocolVersion) + self.daemonInstanceID = daemonInstanceID + generation = try container.decode(UInt64.self, forKey: .generation) + host = try container.decode(String.self, forKey: .host) + port = try container.decode(UInt16.self, forKey: .port) + capability = try container.decode(String.self, forKey: .capability) + issuedAt = try container.decode(Double.self, forKey: .issuedAt) + expiresAt = try container.decode(Double.self, forKey: .expiresAt) + if container.contains(.previous) { + let nested = try container.nestedContainer( + keyedBy: PreviousCodingKeys.self, forKey: .previous) + previous = RemoteBridgePreviousWireState( + generation: try nested.decode(UInt64.self, forKey: .generation), + capability: try nested.decode(String.self, forKey: .capability), + expiresAt: try nested.decode(Double.self, forKey: .expiresAt)) + } else { + previous = nil + } + } +} diff --git a/GraphcodeKit/Sources/Platform/SSHExecutable.swift b/GraphcodeKit/Sources/Platform/SSHExecutable.swift new file mode 100644 index 00000000..bce44fc1 --- /dev/null +++ b/GraphcodeKit/Sources/Platform/SSHExecutable.swift @@ -0,0 +1,103 @@ +import Foundation + +/// The SSH destination components kept separate until argv construction. +/// In particular, an IPv6 host is never confused with a `host:port` authority. +public struct WindowsSSHAuthority: Equatable, Sendable { + public let user: String? + public let host: String + public let port: UInt16? + + public init(user: String? = nil, host: String, port: UInt16? = nil) { + self.user = user + if host.first == "[", host.last == "]", host.count >= 2 { + self.host = String(host.dropFirst().dropLast()) + } else { + self.host = host + } + self.port = port + } + + public var key: String { + let userPart = user.map { "\($0)@" } ?? "" + let hostPart = host.contains(":") ? "[\(host)]" : host + let portPart = port.map { ":\($0)" } ?? "" + return "\(userPart)\(hostPart)\(portPart)" + } + + public var destination: String { + let userPart = user.map { "\($0)@" } ?? "" + let hostPart = host.contains(":") ? "[\(host)]" : host + return "\(userPart)\(hostPart)" + } + + public init?(authority: String) { + let pieces = authority.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + let user: String? + let destination: Substring + if pieces.count == 2 { + guard !pieces[0].isEmpty else { return nil } + user = String(pieces[0]) + destination = pieces[1] + } else { + user = nil + destination = pieces[0] + } + guard !destination.isEmpty else { return nil } + + if destination.first == "[" { + guard let closing = destination.firstIndex(of: "]") else { return nil } + let host = destination[destination.index(after: destination.startIndex).. 0 else { + return nil + } + self.init(user: user, host: String(host), port: port) + return + } + + let colonCount = destination.filter { $0 == ":" }.count + if colonCount > 1 { + self.init(user: user, host: String(destination)) + return + } + if let colon = destination.lastIndex(of: ":") { + let host = destination[.. 0 + else { return nil } + self.init(user: user, host: String(host), port: port) + return + } + self.init(user: user, host: String(destination)) + } +} + +/// Resolves the platform's OpenSSH client without baking a Darwin path into Windows +/// production session commands. +public enum SSHExecutableResolver { + public static func executableURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL? { + #if os(Windows) + let candidates = + environment["PATH"]? + .split(separator: ";") + .map(String.init) + .map { URL(fileURLWithPath: $0).appendingPathComponent("ssh.exe") } + ?? [] + let systemRoot = environment["SystemRoot"] ?? environment["WINDIR"] ?? "C:\\Windows" + let system = URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32/OpenSSH/ssh.exe") + return (candidates + [system]).first { + FileManager.default.isExecutableFile(atPath: $0.path) + } + #else + return URL(fileURLWithPath: "/usr/bin/ssh") + #endif + } +} diff --git a/GraphcodeKit/Sources/Platform/ShellStrategy.swift b/GraphcodeKit/Sources/Platform/ShellStrategy.swift new file mode 100644 index 00000000..c4db9ede --- /dev/null +++ b/GraphcodeKit/Sources/Platform/ShellStrategy.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct WindowsShellStrategy: ShellStrategy { + public var commandPrompt: URL + public var powerShell: URL + + public init( + commandPrompt: URL? = nil, + powerShell: URL? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + self.commandPrompt = commandPrompt ?? Self.defaultCommandPrompt(environment: environment) + self.powerShell = powerShell ?? Self.defaultPowerShell(environment: environment) + } + + public func invocation( + executable: URL, + arguments: [String], + workingDirectory: URL?, + environment: [String: String] + ) throws -> ShellInvocation { + let extensionName = executable.pathExtension.lowercased() + switch extensionName { + case "cmd", "bat": + guard !Self.containsLineBreak(executable.path), + arguments.allSatisfy({ !Self.containsLineBreak($0) }) + else { + throw ShellStrategyError.commandContainsLineBreak + } + let command = + ([Self.quoteCommandPromptArgument(executable.path)] + + arguments.map(Self.quoteCommandPromptArgument)).joined(separator: " ") + return ShellInvocation( + kind: .commandPrompt, + request: ProcessRequest( + executable: commandPrompt, + arguments: ["/d", "/q", "/s", "/c", command], + workingDirectory: workingDirectory, + environment: environment)) + case "ps1": + return ShellInvocation( + kind: .powerShell, + request: ProcessRequest( + executable: powerShell, + arguments: ["-NoLogo", "-NoProfile", "-File", executable.path] + arguments, + workingDirectory: workingDirectory, + environment: environment)) + default: + return ShellInvocation( + kind: .direct, + request: ProcessRequest( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment)) + } + } + + private static func defaultCommandPrompt(environment: [String: String]) -> URL { + if let configured = environmentValue(["ComSpec", "COMSPEC"], in: environment), + !configured.isEmpty + { + return URL(fileURLWithPath: configured) + } + if let systemRoot = environmentValue(["SystemRoot", "WINDIR"], in: environment), + !systemRoot.isEmpty + { + return URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("cmd.exe") + } + return URL(fileURLWithPath: "cmd.exe") + } + + private static func defaultPowerShell(environment: [String: String]) -> URL { + if let configured = environmentValue(["GRAPHCODE_POWERSHELL"], in: environment), + !configured.isEmpty + { + return URL(fileURLWithPath: configured) + } + if let programFiles = environmentValue( + ["ProgramW6432", "ProgramFiles"], + in: environment), + !programFiles.isEmpty + { + let candidate = URL(fileURLWithPath: programFiles) + .appendingPathComponent("PowerShell", isDirectory: true) + .appendingPathComponent("7", isDirectory: true) + .appendingPathComponent("pwsh.exe") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + if let candidate = executableInPath("pwsh.exe", environment: environment) { + return candidate + } + if let systemRoot = environmentValue(["SystemRoot", "WINDIR"], in: environment), + !systemRoot.isEmpty + { + let candidate = URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("WindowsPowerShell", isDirectory: true) + .appendingPathComponent("v1.0", isDirectory: true) + .appendingPathComponent("powershell.exe") + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + if let candidate = executableInPath("powershell.exe", environment: environment) { + return candidate + } + return URL(fileURLWithPath: "powershell.exe") + } + + private static func executableInPath( + _ executable: String, + environment: [String: String] + ) -> URL? { + guard let path = environmentValue(["PATH"], in: environment) else { return nil } + for directory in path.split(separator: ";", omittingEmptySubsequences: true) { + let candidate = URL(fileURLWithPath: String(directory), isDirectory: true) + .appendingPathComponent(executable) + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } + + private static func quoteCommandPromptArgument(_ value: String) -> String { + var quoted = "\"" + for character in value { + if "^&|<>()!%".contains(character) { + quoted.append("^") + } + if character == "\"" { + quoted.append("^") + } + quoted.append(character) + } + quoted.append("\"") + return quoted + } + + private static func containsLineBreak(_ value: String) -> Bool { + value.contains("\r") || value.contains("\n") + } +} +public struct DarwinShellStrategy: ShellStrategy { + public var shell: URL + + public init(shell: URL = URL(fileURLWithPath: "/bin/zsh")) { + self.shell = shell + } + + public func invocation( + executable: URL, + arguments: [String], + workingDirectory: URL?, + environment: [String: String] + ) throws -> ShellInvocation { + ShellInvocation( + kind: .posix, + request: ProcessRequest( + executable: shell, + arguments: [ + "-l", "-c", ([executable.path] + arguments).map(Self.quote).joined(separator: " "), + ], + workingDirectory: workingDirectory, + environment: environment)) + } + + private static func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +public struct DirectShellStrategy: ShellStrategy { + public init() {} + + public func invocation( + executable: URL, + arguments: [String], + workingDirectory: URL?, + environment: [String: String] + ) throws -> ShellInvocation { + ShellInvocation( + kind: .direct, + request: ProcessRequest( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment)) + } +} + +#if os(Windows) + public typealias DefaultShellStrategy = WindowsShellStrategy +#else + public typealias DefaultShellStrategy = DarwinShellStrategy +#endif + +public typealias PosixShellStrategy = DarwinShellStrategy +private func environmentValue(_ keys: [String], in environment: [String: String]) -> String? { + for key in keys { + if let value = environment[key] { + return value + } + } + for (key, value) in environment + where keys.contains(where: { $0.caseInsensitiveCompare(key) == .orderedSame }) { + return value + } + return nil +} diff --git a/GraphcodeKit/Sources/Platform/WindowsAwakeAssertion.swift b/GraphcodeKit/Sources/Platform/WindowsAwakeAssertion.swift new file mode 100644 index 00000000..fc326524 --- /dev/null +++ b/GraphcodeKit/Sources/Platform/WindowsAwakeAssertion.swift @@ -0,0 +1,11 @@ +#if os(Windows) + public actor AwakeAssertion { + public static let shared = AwakeAssertion() + + public static func shouldStayAwake(runningLoops: Int, enabled: Bool) -> Bool { + enabled && runningLoops > 0 + } + + public func apply(shouldHold: Bool, runningLoops: Int) {} + } +#endif diff --git a/GraphcodeKit/Sources/Platform/WindowsSessionServices.swift b/GraphcodeKit/Sources/Platform/WindowsSessionServices.swift new file mode 100644 index 00000000..48f3c91c --- /dev/null +++ b/GraphcodeKit/Sources/Platform/WindowsSessionServices.swift @@ -0,0 +1,52 @@ +import Foundation + +#if os(Windows) + /// The Windows daemon build deliberately keeps session launch behind the + /// frozen platform boundary. The ConPTY/zmx provider is supplied by the + /// provider task; graph state and IPC remain fully usable without it. + public enum CLISessionBackend { + public static func ensureSession(_ node: LoopNode, projectPath: String?) {} + public static func terminateSession(_ node: LoopNode, projectPath: String?) {} + public static func deliverMessage( + _ node: LoopNode, + _ text: String, + _ projectPath: String? + ) async -> Bool { + false + } + public static func readUsage( + _ node: LoopNode, + _ projectPath: String? + ) async -> UsageSample? { + nil + } + public static func readActivity( + _ node: LoopNode, + _ projectPath: String? + ) async -> String? { + nil + } + public static func readPresence( + _ node: LoopNode, + _ projectPath: String? + ) async -> PresenceReading { + .unknown + } + } + + public enum ShellPredicateEvaluator { + public static func evaluate(_ predicate: ShellPredicate) async -> Bool { + false + } + + public static func capture(_ predicate: ShellPredicate) async -> String? { + nil + } + } + + public enum PresenceHooks { + public static func codexNotifyOverride(zmxPath: String) -> String { + "echo graphcode-presence > nul" + } + } +#endif diff --git a/GraphcodeKit/Sources/Platform/WindowsStartupManager.swift b/GraphcodeKit/Sources/Platform/WindowsStartupManager.swift new file mode 100644 index 00000000..a3e069b8 --- /dev/null +++ b/GraphcodeKit/Sources/Platform/WindowsStartupManager.swift @@ -0,0 +1,381 @@ +import Foundation + +#if os(Windows) + import WinSDK + + /// Per-user Task Scheduler integration for the Windows daemon. It avoids + /// elevation and keeps startup state in the user's profile, matching the + /// current-user named-pipe ACL. + public struct WindowsStartupManager: StartupManager { + public let daemonURL: URL + public let supportDirectory: URL + public let taskName: String + public let launcherURL: URL + public let taskDefinitionURL: URL + private let pipeOverride: String? + private let userSID: String + private let runner: any ProcessRunner + + public init( + daemonURL: URL = SupportDirectory.binDirectory.appendingPathComponent("graphcoded.exe"), + taskName: String? = nil, + supportDirectory: URL? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + runner: any ProcessRunner = FoundationProcessRunner() + ) throws { + self.daemonURL = daemonURL + let resolvedSupportDirectory = + supportDirectory + ?? SupportDirectory.configuredURL( + environment: environment, + homeDirectory: FileManager.default.homeDirectoryForCurrentUser) + self.supportDirectory = resolvedSupportDirectory + self.pipeOverride = try WindowsNamedPipeEndpoint.normalizedPipeName( + environment: environment) + self.userSID = try WindowsUserIdentity.currentSID() + self.taskName = + try taskName + ?? WindowsNamedPipeEndpoint.taskName( + supportDirectory: resolvedSupportDirectory) + self.launcherURL = daemonURL.deletingLastPathComponent() + .appendingPathComponent("graphcoded-launcher.ps1") + self.taskDefinitionURL = daemonURL.deletingLastPathComponent() + .appendingPathComponent("graphcoded-task.xml") + self.runner = runner + } + + public func installAndStart() async throws { + try writeTaskFiles() + let create = try await run( + arguments: [ + "/Create", "/TN", taskName, "/XML", taskDefinitionURL.path, "/F", + ]) + guard create.exitCode == 0 else { + throw StartupManagerError.commandFailed( + command: "schtasks /Create", output: output(create)) + } + + let start = try await run(arguments: ["/Run", "/TN", taskName]) + guard start.exitCode == 0 else { + throw StartupManagerError.commandFailed( + command: "schtasks /Run", output: output(start)) + } + } + + public func stop() async throws { + guard try await status() == .running else { return } + let stop = try await run(arguments: ["/End", "/TN", taskName]) + guard stop.exitCode == 0 else { + throw StartupManagerError.commandFailed( + command: "schtasks /End", output: output(stop)) + } + } + + public func waitForDaemonExit(timeout: TimeInterval = 10) async throws { + let probe: @Sendable () -> Bool = { [self] in isDaemonProcessRunning() } + try await Self.waitForExit(timeout: timeout, isRunning: probe) + } + + static func waitForExit( + timeout: TimeInterval, + isRunning: @escaping @Sendable () -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(max(0, timeout)) + while Date() < deadline { + if !isRunning() { return } + try await Task.sleep(for: .milliseconds(50)) + } + throw StartupManagerError.commandFailed( + command: "graphcoded termination", output: "the daemon process is still running") + } + + public func uninstall() async throws { + let query = try await run(arguments: ["/Query", "/TN", taskName]) + guard query.exitCode == 0 else { return } + let delete = try await run(arguments: ["/Delete", "/TN", taskName, "/F"]) + guard delete.exitCode == 0 else { + throw StartupManagerError.commandFailed( + command: "schtasks /Delete", output: output(delete)) + } + } + + public func stopAndUninstall() async throws { + try await stop() + try await waitForDaemonExit() + try await uninstall() + } + + public func status() async throws -> StartupStatus { + let query = try await run(arguments: ["/Query", "/TN", taskName]) + return Self.status( + taskQuerySucceeded: query.exitCode == 0, + daemonProcessRunning: isDaemonProcessRunning()) + } + + static func status(taskQuerySucceeded: Bool, daemonProcessRunning: Bool) -> StartupStatus { + guard taskQuerySucceeded else { return .notInstalled } + return daemonProcessRunning ? .running : .stopped + } + + public func isDaemonProcessRunning() -> Bool { + let targetName = daemonURL.lastPathComponent.lowercased() + guard let currentSID = try? WindowsUserIdentity.currentSID(), + let snapshot = CreateToolhelp32Snapshot(DWORD(TH32CS_SNAPPROCESS), 0), + snapshot != INVALID_HANDLE_VALUE + else { + return false + } + defer { _ = CloseHandle(snapshot) } + + var entry = PROCESSENTRY32W() + entry.dwSize = DWORD(MemoryLayout.size) + guard Process32FirstW(snapshot, &entry) else { return false } + repeat { + let name = withUnsafeBytes(of: &entry.szExeFile) { bytes in + let units = bytes.bindMemory(to: UInt16.self) + let end = units.firstIndex(of: 0) ?? units.count + return String(decoding: units[.. Bool { + guard + let process = OpenProcess( + DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, processID) + else { + return false + } + defer { _ = CloseHandle(process) } + + var buffer = [WCHAR](repeating: 0, count: 32_768) + var length = DWORD(buffer.count) + guard + buffer.withUnsafeMutableBufferPointer({ + QueryFullProcessImageNameW(process, 0, $0.baseAddress, &length) + }) + else { + return false + } + let actualPath = String(decoding: buffer.prefix(Int(length)), as: UTF16.self) + return actualPath.caseInsensitiveCompare(expectedPath) == .orderedSame + } + + private static func processBelongsToCurrentUser(_ processID: DWORD, sid: String) -> Bool { + guard + let process = OpenProcess( + DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, processID) + else { + return false + } + defer { _ = CloseHandle(process) } + var token: HANDLE? + guard OpenProcessToken(process, DWORD(TOKEN_QUERY), &token), let token else { + return false + } + defer { _ = CloseHandle(token) } + var required: DWORD = 0 + _ = GetTokenInformation(token, TokenUser, nil, 0, &required) + guard required > 0 else { return false } + let memory = UnsafeMutableRawPointer.allocate( + byteCount: Int(required), alignment: MemoryLayout.alignment) + defer { memory.deallocate() } + guard GetTokenInformation(token, TokenUser, memory, required, &required) else { + return false + } + let tokenUser = memory.assumingMemoryBound(to: TOKEN_USER.self).pointee + var stringSID: LPWSTR? + guard ConvertSidToStringSidW(tokenUser.User.Sid, &stringSID), let stringSID else { + return false + } + defer { _ = LocalFree(HLOCAL(stringSID)) } + return String(decodingCString: stringSID, as: UTF16.self) + .caseInsensitiveCompare(sid) == .orderedSame + } + + private func run(arguments: [String]) async throws -> ProcessResult { + let executable = + systemRootURL + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("schtasks.exe") + return try await runner.run( + ProcessRequest(executable: executable, arguments: arguments), + timeout: .seconds(30)) + } + + private var systemRootURL: URL { + let systemRoot = + ProcessInfo.processInfo.environment["SystemRoot"] + ?? ProcessInfo.processInfo.environment["WINDIR"] + ?? "C:\\Windows" + return URL(fileURLWithPath: systemRoot) + } + + private var powerShellURL: URL { + systemRootURL + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("WindowsPowerShell", isDirectory: true) + .appendingPathComponent("v1.0", isDirectory: true) + .appendingPathComponent("powershell.exe") + } + + private func writeTaskFiles() throws { + try writeLauncher() + try Self.taskDefinitionContents( + daemonURL: daemonURL, + supportDirectory: supportDirectory, + launcherURL: launcherURL, + powerShellURL: powerShellURL, + userSID: userSID, + pipeOverride: pipeOverride + ).write(to: taskDefinitionURL, atomically: true, encoding: .utf16) + } + + static func taskDefinitionContents( + daemonURL: URL, + supportDirectory: URL, + launcherURL: URL? = nil, + powerShellURL: URL = URL( + fileURLWithPath: + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), + userSID: String = "S-1-5-18", + pipeOverride: String? = nil + ) -> String { + _ = pipeOverride + let effectiveLauncherURL = + launcherURL + ?? daemonURL.deletingLastPathComponent() + .appendingPathComponent("graphcoded-launcher.ps1") + let arguments = + "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass " + + "-File \"\(windowsPath(effectiveLauncherURL))\"" + return """ + + + + Graphcode + Graphcode daemon for \(xmlLiteral(userSID)) + + + + true + \(xmlLiteral(userSID)) + + + + + \(xmlLiteral(userSID)) + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + PT0S + 7 + + + + \(xmlLiteral(windowsPath(powerShellURL))) + \(xmlLiteral(arguments)) + \(xmlLiteral(windowsPath(supportDirectory))) + + + + """.replacingOccurrences(of: "\n", with: "\r\n") + } + + private static func windowsPath(_ url: URL) -> String { + url.path.replacingOccurrences(of: "/", with: "\\") + } + + private static func xmlLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + private func writeLauncher() throws { + try FileManager.default.createDirectory( + at: launcherURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let legacyLauncher = launcherURL.deletingLastPathComponent() + .appendingPathComponent("graphcoded-launcher.cmd") + try? FileManager.default.removeItem(at: legacyLauncher) + try Self.launcherContents( + daemonURL: daemonURL, + supportDirectory: supportDirectory, + pipeOverride: pipeOverride + ).write(to: launcherURL, atomically: true, encoding: .utf16) + } + + func launcherIsCurrent() -> Bool { + guard + let contents = try? String(contentsOf: launcherURL, encoding: .utf16) + else { + return false + } + return contents + == Self.launcherContents( + daemonURL: daemonURL, + supportDirectory: supportDirectory, + pipeOverride: pipeOverride) + } + + static func launcherContents( + daemonURL: URL, + supportDirectory: URL, + pipeOverride: String? = nil + ) -> String { + var lines = [ + "$env:GRAPHCODE_SUPPORT_DIR = \(powerShellLiteral(supportDirectory.path))" + ] + if let pipeOverride { + lines.append( + "$env:GRAPHCODE_SOCKET = \(powerShellLiteral(pipeOverride))") + } + lines.append(contentsOf: [ + "& \(powerShellLiteral(daemonURL.path)) @args", + "exit $LASTEXITCODE", + ]) + return lines.joined(separator: "\r\n") + "\r\n" + } + + private static func powerShellLiteral(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "''"))'" + } + + private func output(_ result: ProcessResult) -> String { + String(decoding: result.standardOutput + result.standardError, as: UTF8.self) + } + } + + public enum StartupManagerError: Error, Equatable, LocalizedError, Sendable { + case commandFailed(command: String, output: String) + case missingRuntimeFiles + + public var errorDescription: String? { + switch self { + case .commandFailed(let command, let output): + return "\(command) failed: \(output)" + case .missingRuntimeFiles: + return "The packaged Windows helpers do not include Swift runtime DLLs." + } + } + } +#endif diff --git a/GraphcodeKit/Sources/ProjectPersistence.swift b/GraphcodeKit/Sources/ProjectPersistence.swift index ef395400..82c33a95 100644 --- a/GraphcodeKit/Sources/ProjectPersistence.swift +++ b/GraphcodeKit/Sources/ProjectPersistence.swift @@ -13,11 +13,17 @@ public struct ProjectPersistence: Sendable { private let projectsDirectory: URL private let recentProjectsFile: URL private let openProjectsFile: URL + private let platformPaths: any PlatformPaths public init(baseDirectory: URL) { + self.init(baseDirectory: baseDirectory, platformPaths: CurrentPlatformPaths.value) + } + + public init(baseDirectory: URL, platformPaths: any PlatformPaths) { projectsDirectory = baseDirectory.appendingPathComponent("projects", isDirectory: true) recentProjectsFile = baseDirectory.appendingPathComponent("recent-projects.json") openProjectsFile = baseDirectory.appendingPathComponent("open-projects.json") + self.platformPaths = platformPaths try? FileManager.default.createDirectory( at: projectsDirectory, withIntermediateDirectories: true) } @@ -25,7 +31,31 @@ public struct ProjectPersistence: Sendable { // MARK: - Per-project graph public func loadGraph(path: String) -> LoopGraph? { - guard let data = try? Data(contentsOf: fileURL(forProjectPath: path)) else { return nil } + let currentURL = fileURL(forProjectPath: path) + if let graph = decodeGraph(at: currentURL) { + return graph + } + + // Before v1 keys, macOS used the path itself as the filename. Keep this fallback + // one-way: a successful read immediately moves the bytes to the safe filename so + // future launches no longer depend on the legacy spelling. + let legacyURL = legacyFileURL(forProjectPath: path) + guard let legacyData = try? Data(contentsOf: legacyURL), + let legacyGraph = try? JSONDecoder().decode(LoopGraph.self, from: legacyData), + pathsMatch(legacyGraph.project.path, path) + else { return nil } + if (try? legacyData.write(to: currentURL, options: .atomic)) != nil { + try? FileManager.default.removeItem(at: legacyURL) + } + return decodeGraph(data: legacyData) + } + + private func decodeGraph(at url: URL) -> LoopGraph? { + guard let data = try? Data(contentsOf: url) else { return nil } + return decodeGraph(data: data) + } + + private func decodeGraph(data: Data) -> LoopGraph? { guard var graph = try? JSONDecoder().decode(LoopGraph.self, from: data) else { return nil } for index in graph.nodes.indices { graph.nodes[index].presence = nil @@ -36,7 +66,9 @@ public struct ProjectPersistence: Sendable { public func saveGraph(_ graph: LoopGraph) { guard let data = try? JSONEncoder().encode(graph) else { return } - try? data.write(to: fileURL(forProjectPath: graph.project.path), options: .atomic) + let currentURL = fileURL(forProjectPath: graph.project.path) + guard (try? data.write(to: currentURL, options: .atomic)) != nil else { return } + removeLegacyGraphIfMatching(path: graph.project.path) } /// Throws away a project's loops for good — the "Delete Loops…" half of the sidebar's @@ -45,16 +77,39 @@ public struct ProjectPersistence: Sendable { /// written to, deleted from, or otherwise modified. public func deleteGraph(path: String) { try? FileManager.default.removeItem(at: fileURL(forProjectPath: path)) + removeLegacyGraphIfMatching(path: path) } - /// Filenames are the canonical path with `/` replaced by `_` — simple, deterministic, - /// and legible in a Finder window, which matters more here than collision-resistance - /// does for a single-user local tool. + /// Filenames are versioned hashes of the canonical project path. A path-derived filename + /// must be deterministic across launches, but Windows also rejects `:`, `\`, and several + /// other characters that occur in perfectly valid project paths. Hashing keeps names + /// short, safe, and collision-resistant without leaking a path into a directory listing. private func fileURL(forProjectPath path: String) -> URL { + let key = platformPaths.persistenceKey(forProjectPath: path) + return projectsDirectory.appendingPathComponent("\(key).json") + } + + private func legacyFileURL(forProjectPath path: String) -> URL { let safeName = path.replacingOccurrences(of: "/", with: "_") return projectsDirectory.appendingPathComponent("\(safeName).json") } + private func removeLegacyGraphIfMatching(path: String) { + let legacyURL = legacyFileURL(forProjectPath: path) + guard let graph = decodeGraph(at: legacyURL), + pathsMatch(graph.project.path, path) + else { return } + try? FileManager.default.removeItem(at: legacyURL) + } + + private func pathsMatch(_ storedPath: String, _ requestedPath: String) -> Bool { + if storedPath == requestedPath { return true } + guard let storedCanonical = try? platformPaths.canonicalProjectPath(storedPath), + let requestedCanonical = try? platformPaths.canonicalProjectPath(requestedPath) + else { return false } + return storedCanonical == requestedCanonical + } + // MARK: - Recent projects public func loadRecentProjects() -> [ProjectRef] { diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index d6a615e8..2429338f 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -1,5 +1,24 @@ import Foundation +public struct ProjectRegistryCommandResult: Equatable, Sendable { + public let response: DaemonEvent? + public let error: String? + /// A successful command may intentionally have no response payload (for example, + /// `.forgetProject`). The daemon uses this bit to distinguish that outcome from an + /// internal routing failure. + public let succeeded: Bool + + public init( + response: DaemonEvent? = nil, + error: String? = nil, + succeeded: Bool? = nil + ) { + self.response = response + self.error = error + self.succeeded = succeeded ?? (error == nil) + } +} + /// Owns every open project's `GraphStore`, keyed by canonicalized folder path — this is /// what `graphcoded` instantiates instead of a single bare `GraphStore` from Phase 4 on /// (see docs/07-roadmap.md#phase-4--projects). Multi-project routing lives entirely @@ -24,14 +43,21 @@ import Foundation /// `.deleteProjectGraph` additionally discards its saved loops. public actor ProjectRegistry { private let persistence: ProjectPersistence + private let quickChatStore: QuickChatStore + private let platformPaths: any PlatformPaths + private let replayStore: DaemonReplayStore private var stores: [String: GraphStore] = [:] - private var connectionFileDescriptors: [UUID: Int32] = [:] + private var connections: [UUID: DaemonConnectionChannel] = [:] private var connectionProjectPaths: [UUID: Set] = [:] /// Connections that asked for the whole open set (`.restoreOpenProjects`) rather than /// one named project — see `sidebarSubscribers`. private var sidebarConnections: Set = [] private let ensureSession: (@Sendable (LoopNode, String?) -> Void)? private let terminateSession: (@Sendable (LoopNode, String?) -> Void)? + private let startQuickChat: (@Sendable (LoopNode, String?) async -> Result)? + private let terminateQuickChat: (@Sendable (LoopNode, String?) async -> Result)? + private let quickChatExists: (@Sendable (LoopNode, String?) async -> Bool)? + private let enumerateQuickChatSessions: (@Sendable () async -> [UUID])? private let evaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? private let checkPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? private let deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? @@ -45,6 +71,29 @@ public actor ProjectRegistry { /// Runs only while the sleep assertion is held — see `refreshAwakeAssertion`. private var awakeRecheck: Task? + /// Quick Chats are session-backed records too. Reusing the GraphStore launcher + /// closures keeps their zmx identity stable (the chat UUID is the LoopNode UUID) + /// without inventing a second session protocol. + private func quickChatNode(_ chat: QuickChat) -> LoopNode { + LoopNode( + id: chat.id, + title: chat.title, + loopType: .turnBased, + backend: chat.backend, + state: .idle, + createdAt: chat.createdAt) + } + + private func ensureQuickChatSession(_ chat: QuickChat) async -> Result { + guard let startQuickChat else { return .failure(.unavailable("session launcher unavailable")) } + return await startQuickChat(quickChatNode(chat), nil) + } + + private func terminateQuickChatSession(_ chat: QuickChat) async -> Result { + guard let terminateQuickChat else { return .failure(.unavailable("session launcher unavailable")) } + return await terminateQuickChat(quickChatNode(chat), nil) + } + /// These default to the real `ZmxSessionLauncher`/`ShellPredicateEvaluator` closures — /// every `GraphStore` this registry creates gets them, so an unattended node's session /// is (re)started as soon as its project's graph is loaded, torn down when the node is @@ -52,6 +101,8 @@ public actor ProjectRegistry { /// closures, or `nil` to touch no real sessions or subprocesses at all. public init( persistenceDirectory: URL, + platformPaths: any PlatformPaths = CurrentPlatformPaths.value, + replayStore: DaemonReplayStore = DaemonReplayStore(), ensureSession: (@Sendable (LoopNode, String?) -> Void)? = CLISessionBackend.ensureSession, terminateSession: (@Sendable (LoopNode, String?) -> Void)? = CLISessionBackend.terminateSession, @@ -69,9 +120,17 @@ public actor ProjectRegistry { readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = CLISessionBackend.readSummary, readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = - CLISessionBackend.readPresence + CLISessionBackend.readPresence, + startQuickChat: (@Sendable (LoopNode, String?) async -> Result)? = nil, + terminateQuickChat: (@Sendable (LoopNode, String?) async -> Result)? = nil, + quickChatExists: (@Sendable (LoopNode, String?) async -> Bool)? = nil + , enumerateQuickChatSessions: (@Sendable () async -> [UUID])? = nil ) { - persistence = ProjectPersistence(baseDirectory: persistenceDirectory) + self.platformPaths = platformPaths + persistence = ProjectPersistence( + baseDirectory: persistenceDirectory, platformPaths: platformPaths) + quickChatStore = QuickChatStore(baseDirectory: persistenceDirectory) + self.replayStore = replayStore self.ensureSession = ensureSession self.terminateSession = terminateSession self.evaluatePredicate = evaluatePredicate @@ -82,24 +141,77 @@ public actor ProjectRegistry { self.readActivity = readActivity self.readSummary = readSummary self.readPresence = readPresence + self.startQuickChat = startQuickChat ?? { node, path in + let result = await CLISessionBackend.backend(for: node).startResult(node, path) + if case .success = result { QuickChatSessionRegistry.markLive(node.id) } + return result + } + self.terminateQuickChat = terminateQuickChat ?? { node, path in + let result = await CLISessionBackend.backend(for: node).terminateResult(node, path) + if case .success = result { QuickChatSessionRegistry.remove(node.id) } + return result + } + self.quickChatExists = quickChatExists ?? { node, path in + await CLISessionBackend.backend(for: node).exists(node, path) + } + self.enumerateQuickChatSessions = enumerateQuickChatSessions ?? { + await CLISessionBackend.backend(for: .init(title: "", backend: .claudeCode)).enumerate() + } } // MARK: - Connections - public func addConnection(id: UUID, fileDescriptor: Int32) { - connectionFileDescriptors[id] = fileDescriptor + public func addConnection( + id: UUID, + connection: any DaemonConnection, + mode: DaemonProtocolMode = .v1, + clientID: UUID? = nil, + subscription: DaemonWireSubscription? = nil, + replayStore: DaemonReplayStore? = nil + ) async { + let channel = DaemonConnectionChannel( + connection: connection, mode: mode, clientID: clientID, + subscription: subscription, replayStore: replayStore ?? self.replayStore) + await addConnection(id: id, channel: channel) + } + + public func addConnection(id: UUID, channel: DaemonConnectionChannel) async { + connections[id] = channel + // Reattach every persisted chat on reconnect. zmx's stable node ID makes this + // idempotent when the previous daemon instance is still winding down. + if let chats = try? quickChatStore.loadResult(), case .loaded(let loaded) = chats { + for chat in loaded { + _ = await ensureQuickChatSession(chat) + } + let known = Set(loaded.map(\.id)) + for orphan in await (enumerateQuickChatSessions?() ?? []) where !known.contains(orphan) { + _ = await terminateQuickChatSession( + QuickChat(id: orphan, title: "orphan", backend: .claudeCode)) + } + } startPresencePolling() } + #if canImport(Darwin) + /// Compatibility seam for existing macOS callers; the registry stores only the + /// channel abstraction after this boundary. + public func addConnection(id: UUID, fileDescriptor: Int32) async { + await addConnection( + id: id, + connection: UnixSocketConnection(fileDescriptor: fileDescriptor)) + } + #endif + public func removeConnection(_ id: UUID) async { for path in connectionProjectPaths[id] ?? [] { guard let store = stores[path] else { continue } await store.removeConnection(id) } - connectionFileDescriptors.removeValue(forKey: id) + let channel = connections.removeValue(forKey: id) connectionProjectPaths.removeValue(forKey: id) sidebarConnections.remove(id) - if connectionFileDescriptors.isEmpty { stopPresencePolling() } + if connections.isEmpty { stopPresencePolling() } + try? await channel?.close() } // MARK: - Presence polling @@ -235,15 +347,72 @@ public actor ProjectRegistry { // MARK: - Commands public func handle(_ command: DaemonCommand, connectionID: UUID) async { - guard let fileDescriptor = connectionFileDescriptors[connectionID] else { return } + guard let result = await apply(command, connectionID: connectionID), + let message = result.error, + let channel = connections[connectionID], + case .v1 = channel.mode + else { return } + if case .graphCommand(let path, _) = command, + stores[Self.canonicalize(path, platformPaths: platformPaths)] != nil + { + // GraphStore has already emitted this rejection to its v1 subscribers. + return + } + await send(.errorOccurred(message), to: connectionID) + } + + /// Called by the daemon's session/activity poller. Sequence numbers are persisted with + /// the chat so reconnecting clients can order updates deterministically. + public func updateQuickChatActivity( + id: UUID, + text: String?, + presence: PresenceReading? + ) async -> Bool { + guard let chat = quickChatStore.chat(id: id) else { return false } + let sequence = (chat.activity?.sequence ?? 0) + 1 + let activity = QuickChatActivity(sequence: sequence, text: text, presence: presence) + guard (try? quickChatStore.updateActivity(id: id, activity: activity)) != nil else { return false } + await broadcast(.quickChatActivity(id: id, activity: activity)) + return true + } + + /// Applies a command and snapshots its correlated result before returning to the + /// daemon read loop. Keeping mutation and response selection together prevents a + /// concurrent disconnect or command from turning a rejected mutation into a stale + /// successful graph response. + public func apply( + _ command: DaemonCommand, + connectionID: UUID + ) async -> ProjectRegistryCommandResult? { + guard let channel = connections[connectionID] else { return nil } + let broadcastErrors: Bool + if case .v2 = channel.mode { + broadcastErrors = false + } else { + broadcastErrors = true + } + var response: DaemonEvent? = nil + var error: String? = nil switch command { case .listRecentProjects: - send(.recentProjectsListed(persistence.loadRecentProjects()), to: fileDescriptor) + let recentProjects = persistence.loadRecentProjects() + if case .v1 = channel.mode { + await send(.recentProjectsListed(recentProjects), to: connectionID) + } + response = .recentProjectsListed(recentProjects) + error = nil case .openProject(let path): - guard Self.isOpenable(path) else { break } - await open(Self.canonicalize(path), for: connectionID, fileDescriptor: fileDescriptor) + guard Self.isOpenable(path, platformPaths: platformPaths) else { + return ProjectRegistryCommandResult(error: "project path is not openable") + } + let snapshot = await open( + Self.canonicalize(path, platformPaths: platformPaths), + for: connectionID, + channel: channel) + response = .graphChanged(snapshot) + error = nil case .restoreOpenProjects: // Each of these broadcasts a `.graphChanged` exactly as `.openProject` would, so @@ -258,24 +427,37 @@ public actor ProjectRegistry { // it is joined to projects *other* clients open, so `graphcode status ` // puts a row in a running app instead of one that only appears next launch. sidebarConnections.insert(connectionID) - for path in persistence.loadOpenProjects() where Self.isWellFormedProjectPath(path) { - await open(path, for: connectionID, fileDescriptor: fileDescriptor) + for path in persistence.loadOpenProjects() + where Self.isWellFormedProjectPath(path, platformPaths: platformPaths) { + await open( + Self.canonicalize(path, platformPaths: platformPaths), + for: connectionID, + channel: channel) } + response = .recentProjectsListed(persistence.loadRecentProjects()) + error = nil case .openGlobalGraph: - await open(LoopGraphScope.globalPath, for: connectionID, fileDescriptor: fileDescriptor) + let snapshot = await open(LoopGraphScope.globalPath, for: connectionID, channel: channel) + response = .graphChanged(snapshot) + error = nil case .closeProject(let path): - await close(Self.canonicalize(path), for: connectionID) + let snapshot = await close( + Self.canonicalize(path, platformPaths: platformPaths), + for: connectionID) + response = snapshot.map(DaemonEvent.graphChanged) + error = nil case .forgetProject(let path): - let canonicalPath = Self.canonicalize(path) - await close(canonicalPath, for: connectionID) + let canonicalPath = Self.canonicalize(path, platformPaths: platformPaths) + _ = await close(canonicalPath, for: connectionID) persistence.forgetProject(path: canonicalPath) + error = nil case .deleteProjectGraph(let path): - let canonicalPath = Self.canonicalize(path) - await close(canonicalPath, for: connectionID) + let canonicalPath = Self.canonicalize(path, platformPaths: platformPaths) + _ = await close(canonicalPath, for: connectionID) persistence.forgetProject(path: canonicalPath) // The graph is the only handle on every loop's detached session, so its deletion // has to end them first — dropping it with the sessions alive left every agent in @@ -293,26 +475,162 @@ public actor ProjectRegistry { // just deleted from the one still sitting in `stores`. stores.removeValue(forKey: canonicalPath) persistence.deleteGraph(path: canonicalPath) + response = .recentProjectsListed(persistence.loadRecentProjects()) + error = nil + + case .listQuickChats: + guard let chats = try? quickChatStore.loadResult() else { + error = "quick chat store is corrupt or unreadable" + break + } + switch chats { + case .missing: response = .quickChatsListed([]) + case .loaded(let values): response = .quickChatsListed(values) + } + await broadcast(response!) + + case .createQuickChat(let title, let backend): + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + error = "quick chat title must not be empty" + break + } + let chat = QuickChat(title: trimmed, backend: backend) + do { + try quickChatStore.create(chat) + } catch _ { + error = "quick chat persistence failed" + break + } + response = .quickChatChanged(chat) + await broadcast(response!) + + case .openQuickChat(let id): + guard let chat = quickChatStore.chat(id: id) else { + error = "quick chat not found" + break + } + switch await ensureQuickChatSession(chat) { + case .failure(let failure): + error = "quick chat session unavailable: \(failure)" + break + case .success: + response = .quickChatChanged(chat) + await broadcast(response!) + } + if error != nil { break } + + case .renameQuickChat(let id, let title): + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + error = "quick chat title must not be empty" + break + } + guard let chat = (try? quickChatStore.rename(id: id, title: trimmed)) ?? nil else { + error = "quick chat not found" + break + } + response = .quickChatChanged(chat) + await broadcast(response!) + + case .deleteQuickChat(let id): + guard let chat = quickChatStore.chat(id: id) else { + error = "quick chat not found" + break + } + // Stop and confirm first. The record remains durable on failure so reconnect + // can retry rather than leaving an untracked live session. + guard case .success = await terminateQuickChatSession(chat) else { + error = "quick chat session termination failed" + break + } + do { + _ = try quickChatStore.delete(id: id) + } catch _ { + _ = await ensureQuickChatSession(chat) + error = "quick chat persistence failed" + break + } + response = .quickChatDeleted(id) + await broadcast(response!) case .graphCommand(let path, let inner): - guard let store = stores[Self.canonicalize(path)] else { return } - await store.handle(inner) + guard let store = stores[Self.canonicalize(path, platformPaths: platformPaths)] else { + return ProjectRegistryCommandResult(error: "project is not open") + } + let v2PayloadLimit: Int? + if case .v2 = channel.mode { + v2PayloadLimit = FramedMessageIO.v2MaxPayloadBytes + } else { + v2PayloadLimit = nil + } + let result = await store.handle( + inner, + broadcastErrors: broadcastErrors, + v2PayloadLimit: v2PayloadLimit) + switch result { + case .applied(let graph): + response = .graphChanged(graph) + error = nil + case .rejected(let message, _): + error = message + } + } + + return ProjectRegistryCommandResult(response: error == nil ? response : nil, error: error) + } + + /// Produces a correlated v2 response after the command has been applied. The v1 + /// protocol continues to use its existing broadcast-only acknowledgement path. + public func responseEvent(for command: DaemonCommand) async -> DaemonEvent? { + switch command { + case .listRecentProjects: + return .recentProjectsListed(persistence.loadRecentProjects()) + case .restoreOpenProjects: + return .recentProjectsListed(persistence.loadRecentProjects()) + case .openProject(let path), .closeProject(let path), .forgetProject(let path): + let canonical = Self.canonicalize(path, platformPaths: platformPaths) + guard let store = stores[canonical] else { return nil } + return .graphChanged(await store.graph) + case .deleteProjectGraph: + return .recentProjectsListed(persistence.loadRecentProjects()) + case .listQuickChats: + guard let chats = try? quickChatStore.loadResult() else { return nil } + switch chats { + case .missing: return .quickChatsListed([]) + case .loaded(let values): return .quickChatsListed(values) + } + case .createQuickChat, .openQuickChat, .renameQuickChat: + return nil + case .deleteQuickChat(let id): + return .quickChatDeleted(id) + case .openGlobalGraph: + guard let store = stores[LoopGraphScope.globalPath] else { return nil } + return .graphChanged(await store.graph) + case .graphCommand(let path, _): + guard let store = stores[Self.canonicalize(path, platformPaths: platformPaths)] else { + return nil + } + return .graphChanged(await store.graph) } } - private func open(_ canonicalPath: String, for connectionID: UUID, fileDescriptor: Int32) async { + private func open( + _ canonicalPath: String, for connectionID: UUID, channel: DaemonConnectionChannel + ) async -> LoopGraph { let store = await store(forProjectPath: canonicalPath) connectionProjectPaths[connectionID, default: []].insert(canonicalPath) - await store.addConnection(id: connectionID, fileDescriptor: fileDescriptor) + let snapshot = await store.addConnection(id: connectionID, channel: channel) // The global graph is always resident and isn't a folder anyone opened, so it stays // out of both the recents list and the restore-on-launch set — the app asks for it // by name every launch instead. - guard canonicalPath != LoopGraphScope.globalPath else { return } - let project = await store.graph.project + guard canonicalPath != LoopGraphScope.globalPath else { return snapshot } + let project = snapshot.project persistence.recordOpened( ProjectRef(path: project.path, name: project.name, lastOpenedAt: Date())) - guard rememberOpen(canonicalPath) else { return } + guard rememberOpen(canonicalPath) else { return snapshot } await joinSidebars(to: store, at: canonicalPath, excluding: connectionID) + return snapshot } /// Joins every attached sidebar client to a project one of *them* — or the CLI, or a @@ -331,18 +649,20 @@ public actor ProjectRegistry { /// would hand it another project's graph to print. private func joinSidebars(to store: GraphStore, at path: String, excluding opener: UUID) async { for id in sidebarConnections where id != opener { - guard let fileDescriptor = connectionFileDescriptors[id] else { continue } + guard let channel = connections[id] else { continue } connectionProjectPaths[id, default: []].insert(path) - await store.addConnection(id: id, fileDescriptor: fileDescriptor) + _ = await store.addConnection(id: id, channel: channel) } } - private func close(_ canonicalPath: String, for connectionID: UUID) async { + private func close(_ canonicalPath: String, for connectionID: UUID) async -> LoopGraph? { + var snapshot: LoopGraph? if let store = stores[canonicalPath] { - await store.removeConnection(connectionID) + snapshot = await store.removeConnection(connectionID, leaveReplay: true) } connectionProjectPaths[connectionID]?.remove(canonicalPath) persistence.saveOpenProjects(persistence.loadOpenProjects().filter { $0 != canonicalPath }) + return snapshot } /// Append rather than insert-at-front: the sidebar should come back in the order it @@ -370,8 +690,8 @@ public actor ProjectRegistry { /// broadcasting, and command routing identical to a project's. What makes it global is /// where its `.spawn` edges are allowed to point, not a separate code path. public func openGlobalGraph(for connectionID: UUID) async { - guard let fileDescriptor = connectionFileDescriptors[connectionID] else { return } - await open(LoopGraphScope.globalPath, for: connectionID, fileDescriptor: fileDescriptor) + guard let channel = connections[connectionID] else { return } + await open(LoopGraphScope.globalPath, for: connectionID, channel: channel) } /// Delivers a cross-graph spawn into its target project. @@ -385,7 +705,7 @@ public actor ProjectRegistry { /// nothing spawns back. Enforced here rather than trusted: a project graph naming the /// global path as its spawn target is refused. private func spawnIntoProject(_ targetPath: String, draft: NodeDraft) async { - let canonicalPath = Self.canonicalize(targetPath) + let canonicalPath = Self.canonicalize(targetPath, platformPaths: platformPaths) guard canonicalPath != LoopGraphScope.globalPath else { return } guard let store = stores[canonicalPath] else { return } await store.handle(.createNode(draft)) @@ -398,12 +718,16 @@ public actor ProjectRegistry { let scope = LoopGraphScope(projectPath: path, name: Self.displayName(for: path)) let graph = persistence.loadGraph(path: path) ?? LoopGraph(scope: scope) let persistence = self.persistence + let replayStore = self.replayStore // A cross-graph spawn arrives here as a plain request; hopping through an unstructured // `Task` is what lets this actor re-enter itself to reach a *different* store without // deadlocking on its own isolation. let spawnIntoProject: @Sendable (String, NodeDraft) -> Void = { [weak self] target, draft in Task { await self?.spawnIntoProject(target, draft: draft) } } + let onConnectionFailure: @Sendable (UUID) -> Void = { [weak self] connectionID in + Task { await self?.removeConnection(connectionID) } + } let newStore = GraphStore( graph: graph, onGraphChanged: { [weak self] updatedGraph in @@ -412,6 +736,12 @@ public actor ProjectRegistry { // the first to have started — see `refreshAwakeAssertion`. Task { await self?.refreshAwakeAssertion() } }, + onGraphEvent: { event in + guard case .graphChanged(let updatedGraph) = event else { return [:] } + return replayStore.append( + event: event, projectPath: updatedGraph.project.path) + }, + onConnectionFailure: onConnectionFailure, onEnsureSession: ensureSession, onTerminateSession: terminateSession, onEvaluatePredicate: evaluatePredicate, @@ -466,13 +796,13 @@ public actor ProjectRegistry { /// /// The root is refused even when spelled out. A project is scanned by the worktree /// sweeper and by git; pointed at `/` that is the whole disk. - static func isWellFormedProjectPath(_ path: String) -> Bool { + static func isWellFormedProjectPath( + _ path: String, + platformPaths: any PlatformPaths = CurrentPlatformPaths.value + ) -> Bool { if path == LoopGraphScope.globalPath { return true } if RemoteProjectLocation.parse(projectPath: path) != nil { return true } - // Absolute, and not the root however it is spelled: `/`, `//`, `/..` and `/a/..` all - // reduce to the same directory. - guard path.hasPrefix("/") else { return false } - return RemoteProjectLocation.normalizedPath(path) != "/" + return (try? platformPaths.canonicalProjectPath(path)) != nil } /// Whether a path can be opened as a project right now: well-formed, and a directory @@ -482,12 +812,19 @@ public actor ProjectRegistry { /// because this is the door every client knocks on, and without it a mistyped or /// already-deleted path became a project with a store, a recents entry and a place in /// the restore set — `~/.graphcode/projects` accumulates one JSON per such ghost. - static func isOpenable(_ path: String) -> Bool { - guard isWellFormedProjectPath(path) else { return false } + static func isOpenable( + _ path: String, + platformPaths: any PlatformPaths = CurrentPlatformPaths.value + ) -> Bool { + guard isWellFormedProjectPath(path, platformPaths: platformPaths) else { return false } if path == LoopGraphScope.globalPath { return true } if RemoteProjectLocation.parse(projectPath: path) != nil { return true } + guard let canonicalPath = try? platformPaths.canonicalProjectPath(path) else { + return false + } var isDirectory: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + let exists = FileManager.default.fileExists( + atPath: canonicalPath, isDirectory: &isDirectory) return exists && isDirectory.boolValue } @@ -495,11 +832,14 @@ public actor ProjectRegistry { /// `.graphChanged` is keyed on it. Public because the app has to key on it too: a /// project it asked for by the path a folder picker handed it comes back named by this, /// and `/tmp` vs `/private/tmp` is enough to make the two look like different projects. - public static func canonicalize(_ path: String) -> String { + public static func canonicalize( + _ path: String, + platformPaths: any PlatformPaths = CurrentPlatformPaths.value + ) -> String { guard path != LoopGraphScope.globalPath, RemoteProjectLocation.parse(projectPath: path) == nil else { return path } - return URL(fileURLWithPath: path).resolvingSymlinksInPath().path + return (try? platformPaths.canonicalProjectPath(path)) ?? path } private static func displayName(for path: String) -> String { @@ -511,8 +851,18 @@ public actor ProjectRegistry { // MARK: - Unicast reply - private func send(_ event: DaemonEvent, to fileDescriptor: Int32) { - guard let data = try? JSONEncoder().encode(event) else { return } - try? FramedMessageIO.writeFrame(data, to: fileDescriptor) + private func send(_ event: DaemonEvent, to connectionID: UUID) async { + guard let channel = connections[connectionID] else { return } + do { + try await channel.sendEvent(event) + } catch { + await removeConnection(connectionID) + } + } + + private func broadcast(_ event: DaemonEvent) async { + for id in connections.keys { + await send(event, to: id) + } } } diff --git a/GraphcodeKit/Sources/QuickChatStore.swift b/GraphcodeKit/Sources/QuickChatStore.swift index 15a55144..c99bb7c4 100644 --- a/GraphcodeKit/Sources/QuickChatStore.swift +++ b/GraphcodeKit/Sources/QuickChatStore.swift @@ -1,12 +1,8 @@ import Foundation /// An ad-hoc backend session with no loop semantics: no goal, no trigger, no place in -/// any graph — just a conversation. Deliberately app-local rather than daemon-owned: -/// the daemon's whole job is graphs, hand-offs, and keeping *unattended* sessions -/// alive, and a chat is attended by definition. What keeps a chat's scrollback across -/// app restarts is its `zmx` session, exactly as for a loop — the id here is the -/// session identity (`SurfaceRef(id:).zmxSessionName`), so reopening a chat joins the -/// same live session. +/// any graph — just a conversation. The daemon owns the record and broadcasts mutations; +/// the app still owns the attended terminal surface and attaches to the same zmx session. public struct QuickChat: Identifiable, Codable, Equatable, Sendable { public let id: UUID public var title: String @@ -15,23 +11,49 @@ public struct QuickChat: Identifiable, Codable, Equatable, Sendable { /// from a different agent. public var backend: CLISessionBackendKind public var createdAt: Date + public var activity: QuickChatActivity? public init( id: UUID = UUID(), title: String, backend: CLISessionBackendKind = .claudeCode, - createdAt: Date = Date() + createdAt: Date = Date(), + activity: QuickChatActivity? = nil ) { self.id = id self.title = title self.backend = backend self.createdAt = createdAt + self.activity = activity + } +} + +public struct QuickChatActivity: Codable, Equatable, Sendable { + public var sequence: UInt64 + public var text: String? + public var presence: PresenceReading? + + public init(sequence: UInt64, text: String? = nil, presence: PresenceReading? = nil) { + self.sequence = sequence + self.text = text + self.presence = presence } } /// Reads/writes the quick-chat list — one JSON file under ``. Same shape /// as `TerminalLayoutStore` and for the same reason: small local file I/O, app-side /// state the daemon has no reason to know about. +public enum QuickChatStoreError: Error, Equatable, Sendable { + case encodingFailed + case persistenceFailed + case corruptOrUnreadable +} + +public enum QuickChatStoreLoad: Equatable, Sendable { + case missing + case loaded([QuickChat]) +} + public struct QuickChatStore: Sendable { private let fileURL: URL @@ -42,12 +64,68 @@ public struct QuickChatStore: Sendable { } public func load() -> [QuickChat] { - guard let data = try? Data(contentsOf: fileURL) else { return [] } - return (try? JSONDecoder().decode([QuickChat].self, from: data)) ?? [] + guard case .loaded(let chats) = (try? loadResult()) ?? .missing else { return [] } + return chats + } + + public func loadResult() throws -> QuickChatStoreLoad { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return .missing } + do { + return .loaded(try JSONDecoder().decode([QuickChat].self, from: Data(contentsOf: fileURL))) + } catch { + throw QuickChatStoreError.corruptOrUnreadable + } + } + + public func save(_ chats: [QuickChat]) throws { + guard let data = try? JSONEncoder().encode(chats) else { + throw QuickChatStoreError.encodingFailed + } + do { + try data.write(to: fileURL, options: .atomic) + } catch { + throw QuickChatStoreError.persistenceFailed + } + } + + public func create(_ chat: QuickChat) throws { + var chats = try loadedChats().filter { $0.id != chat.id } + chats.append(chat) + try save(chats.sorted { $0.createdAt < $1.createdAt }) + } + + public func chat(id: UUID) -> QuickChat? { + load().first { $0.id == id } + } + + public func rename(id: UUID, title: String) throws -> QuickChat? { + var chats = try loadedChats() + guard let index = chats.firstIndex(where: { $0.id == id }) else { return nil } + chats[index].title = title + try save(chats) + return chats[index] + } + + public func delete(id: UUID) throws -> QuickChat? { + var chats = try loadedChats() + guard let index = chats.firstIndex(where: { $0.id == id }) else { return nil } + let removed = chats.remove(at: index) + try save(chats) + return removed + } + + public func updateActivity(id: UUID, activity: QuickChatActivity) throws -> QuickChat? { + var chats = try loadedChats() + guard let index = chats.firstIndex(where: { $0.id == id }) else { return nil } + chats[index].activity = activity + try save(chats) + return chats[index] } - public func save(_ chats: [QuickChat]) { - guard let data = try? JSONEncoder().encode(chats) else { return } - try? data.write(to: fileURL, options: .atomic) + private func loadedChats() throws -> [QuickChat] { + switch try loadResult() { + case .missing: return [] + case .loaded(let chats): return chats + } } } diff --git a/GraphcodeKit/Sources/Sessions/AgentEnvironment.swift b/GraphcodeKit/Sources/Sessions/AgentEnvironment.swift index 3c7e4615..28b644d5 100644 --- a/GraphcodeKit/Sources/Sessions/AgentEnvironment.swift +++ b/GraphcodeKit/Sources/Sessions/AgentEnvironment.swift @@ -23,8 +23,10 @@ public enum AgentEnvironment { /// through `PTYProcessSession`. Scrubbing at the root covers both paths, and the zmx /// server that outlives them. public static func scrubInheritedAgentIdentity() { + #if !os(Windows) for key in ProcessInfo.processInfo.environment.keys where isInheritedAgentIdentity(key) { unsetenv(key) } + #endif } } diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index fff76699..373cdc3b 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -1,5 +1,16 @@ import Foundation +public enum CLISessionError: Error, Equatable, Sendable { + case unavailable(String) + case failed(String) + case notFound +} + +public enum CLISessionStartOutcome: Equatable, Sendable { + case attached + case started +} + /// The abstraction over Claude Code, Copilot CLI, and Codex — /// docs/04-cli-backends.md#clisessionbackend-protocol. /// @@ -48,6 +59,10 @@ public struct CLISessionBackend: Sendable { /// What the session says it is doing right now, or `nil` when nothing reports it — /// see `LoopNode.activity`. `projectPath` routed as `presence`'s is. public var activity: @Sendable (LoopNode, String?) async -> String? + public var startResult: @Sendable (LoopNode, String?) async -> Result + public var terminateResult: @Sendable (LoopNode, String?) async -> Result + public var exists: @Sendable (LoopNode, String?) async -> Bool + public var enumerate: @Sendable () async -> [UUID] /// The beats this session has narrated, or `nil` when the backend has no transcript to /// read, the loop is remote, or the human hasn't switched the producer on. Folded into /// `LoopNode.summary` by `GraphStore`, never written straight onto the node — see @@ -62,7 +77,11 @@ public struct CLISessionBackend: Sendable { presence: @escaping @Sendable (LoopNode, String?) async -> PresenceReading, usage: @escaping @Sendable (LoopNode, String?) async -> UsageSample?, activity: @escaping @Sendable (LoopNode, String?) async -> String? = { _, _ in nil }, - summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil } + summary: @escaping @Sendable (LoopNode, String?) async -> SummaryReading? = { _, _ in nil }, + startResult: (@Sendable (LoopNode, String?) async -> Result)? = nil, + terminateResult: (@Sendable (LoopNode, String?) async -> Result)? = nil, + exists: (@Sendable (LoopNode, String?) async -> Bool)? = nil + , enumerate: (@Sendable () async -> [UUID])? = nil ) { self.kind = kind self.launch = launch @@ -71,6 +90,16 @@ public struct CLISessionBackend: Sendable { self.presence = presence self.usage = usage self.activity = activity + self.startResult = startResult ?? { node, path in + await launch(node, path) + return .success(.started) + } + self.terminateResult = terminateResult ?? { node, path in + await terminate(node, path) + return .success(()) + } + self.exists = exists ?? { _, _ in false } + self.enumerate = enumerate ?? { [] } self.summary = summary } } @@ -171,7 +200,17 @@ extension CLISessionBackend { guard let reading else { return nil } return await SummaryModelWriter.applied( to: reading, node: node, projectPath: projectPath, settings: settings) - } + }, + startResult: { node, projectPath in + await ZmxSessionLauncher.startResult(node, projectPath: projectPath) + }, + terminateResult: { node, projectPath in + await ZmxSessionLauncher.terminateResult(node, projectPath: projectPath) + }, + exists: { node, projectPath in + await ZmxSessionLauncher.sessionExists(node, projectPath: projectPath) + }, + enumerate: { await ZmxSessionLauncher.enumerateSessionIDs() } ) } @@ -195,7 +234,11 @@ extension CLISessionBackend { terminate: { _, _ in }, sendInput: { _, _, _ in false }, presence: { _, _ in PresenceReading(presence: .absent, confidence: .reported) }, - usage: { _, _ in nil } + usage: { _, _ in nil }, + startResult: { _, _ in .failure(.unavailable("backend is not spiked")) }, + terminateResult: { _, _ in .success(()) }, + exists: { _, _ in false } + , enumerate: { [] } ) } diff --git a/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift b/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift index 06ec616f..c9beb29e 100644 --- a/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift +++ b/GraphcodeKit/Sources/Sessions/ClaudeSessionLog.swift @@ -233,27 +233,3 @@ public enum ClaudeSessionLog { return reading.isEmpty ? nil : reading } } - -/// How the one number the rail carries is written. -/// -/// Which pass a movement belongs to is `LoopSummary.delta`'s answer, and it is matched on -/// *when* the sample was taken. Keying the series by position — sample *n* to pass *n* — -/// was the first version, and it printed a real movement under a pass it did not happen -/// in as soon as a human typed twice in one pass. -/// -/// The values themselves come off `metricHistory` unrecomputed: that series is what the -/// sparkline and the plateau rule already use, so a pass line saying `1.4k → 1.3k` and a -/// bar chart disagreeing is impossible by construction. -public enum LoopSummaryDeltas { - /// Short enough for a 188pt line: `1.4k`, `0.62`, `312`. - public static func number(_ value: Double) -> String { - let magnitude = abs(value) - if magnitude >= 1000 { - return String(format: "%.1fk", value / 1000) - } - if magnitude >= 100 || value == value.rounded() { - return String(format: "%.0f", value) - } - return String(format: "%.2f", value) - } -} diff --git a/GraphcodeKit/Sources/Sessions/QuickChatSessionRegistry.swift b/GraphcodeKit/Sources/Sessions/QuickChatSessionRegistry.swift new file mode 100644 index 00000000..582ec769 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/QuickChatSessionRegistry.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Durable ownership index for daemon-owned Quick Chat zmx sessions. It is +/// intentionally separate from graph loop session IDs so orphan cleanup can never +/// terminate a normal graph session. +public enum QuickChatSessionRegistry { + private static var directory: URL { + SupportDirectory.url.appendingPathComponent("quick-chat-sessions", isDirectory: true) + } + + public static func markLive(_ id: UUID) { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? Data(id.uuidString.utf8).write( + to: directory.appendingPathComponent("\(id.uuidString).id"), options: .atomic) + } + + public static func remove(_ id: UUID) { + try? FileManager.default.removeItem( + at: directory.appendingPathComponent("\(id.uuidString).id")) + } + + public static func ids() -> [UUID] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) else { return [] } + return files.compactMap { + UUID(uuidString: $0.deletingPathExtension().lastPathComponent) + } + } +} diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index b482ca40..f78da4d1 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -24,6 +24,13 @@ public enum RemoteGraphAccess { /// not on your PATH" line stays true verbatim on both kinds of host. public static let cliInstallPath = "~/.graphcode/bin/graphcode" + /// Windows graphcoded publishes this user-only record for the remote loopback + /// bridge. It is deliberately stable per remote home: every project on one + /// forwarded host uses the same authenticated daemon endpoint. + public static let bridgeStatePath = "~/.graphcode/bridge-state.json" + /// Non-secret generation receipt used to decide whether a remote shim needs refresh. + public static let bridgeStateGenerationPath = "~/.graphcode/bridge-state-generation" + /// Where the receipt for the last installed shim lives. /// /// **Named for the shim alone, and it covers the shim alone.** The briefing, wake @@ -126,12 +133,22 @@ public enum RemoteGraphAccess { guard let json = try? JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) else { return nil } let program = - "import base64,json,os,sys; " + "import base64,json,os,sys,tempfile; " + "m=json.loads(base64.b64decode(sys.argv[1])); " - + "[(os.makedirs(os.path.dirname(os.path.expanduser(p)),exist_ok=True), " - + "open(os.path.expanduser(p),'wb').write(base64.b64decode(c)), " - + "os.chmod(os.path.expanduser(p),0o755) if p.endswith('/graphcode') else None) " - + "for p,c in sorted(m.items())]; " + + "exec('def w(p,c):\\n" + + " d=os.path.dirname(os.path.expanduser(p))\\n" + + " os.makedirs(d,exist_ok=True)\\n" + + " b=base64.b64decode(c)\\n" + + " if p.endswith(\"/bridge-state.json\"):\\n" + + " fd,t=tempfile.mkstemp(dir=d)\\n" + + " n=os.write(fd,b)\\n" + + " if n != len(b): os.close(fd); raise OSError(\"short state write\")\\n" + + " os.fsync(fd); os.chmod(t,0o600); os.close(fd); os.replace(t,os.path.expanduser(p))\\n" + + " else:\\n" + + " with open(os.path.expanduser(p),\"wb\") as f: f.write(b)\\n" + + " os.chmod(os.path.expanduser(p),0o755 if p.endswith(\"/graphcode\") else 0o644)\\n" + + "')'); " + + "[w(p,c) for p,c in sorted(m.items())]; " + "len(sys.argv)>2 and open(os.path.expanduser(sys.argv[2]),'w').write(sys.argv[3])" var argv = ["python3", "-c", program, json.base64EncodedString()] if let receipt { argv += [receipt.path, receipt.content] } @@ -139,12 +156,83 @@ public enum RemoteGraphAccess { + " >/dev/null 2>&1 || true" } + /// Installs bridge state through the SSH command's stdin. Only the byte count and + /// SHA-256 digest appear in the remote command; the capability-bearing JSON never + /// appears in argv, shell history, or a process listing. + public static func bridgeStateInstallerScript(length: Int, sha256: String) -> String { + let program = """ + import fcntl + import hashlib + import json + import os + import sys + import tempfile + + count = int(sys.argv[1]) + digest = sys.argv[2] + data = sys.stdin.buffer.read(count) + if len(data) != count or hashlib.sha256(data).hexdigest() != digest: + raise ValueError("invalid bridge state transfer") + state = json.loads(data.decode("utf-8")) + state_path = os.path.expanduser(sys.argv[3]) + generation_path = os.path.expanduser(sys.argv[4]) + directory = os.path.dirname(state_path) + os.makedirs(directory, exist_ok=True) + + def atomic_write(path, payload): + folder = os.path.dirname(path) + fd, temporary = tempfile.mkstemp(dir=folder) + try: + with os.fdopen(fd, "wb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + lock_path = state_path + ".lock" + with open(lock_path, "a+", encoding="ascii") as lock: + os.chmod(lock_path, 0o600) + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + current = None + try: + with open(state_path, "r", encoding="utf-8") as existing: + current = json.load(existing) + except (OSError, ValueError): + pass + current_generation = ( + current.get("generation") + if isinstance(current, dict) + and isinstance(current.get("generation"), int) + and not isinstance(current.get("generation"), bool) + else 0 + ) + incoming_generation = state["generation"] + if current_generation < incoming_generation: + atomic_write(state_path, data) + current_generation = incoming_generation + atomic_write(generation_path, str(current_generation).encode("ascii")) + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + """ + return [ + "python3", "-c", program, String(length), sha256, + bridgeStatePath, bridgeStateGenerationPath, + ].map(RemoteProjectLocation.shellQuoted).joined(separator: " ") + } + /// The remote `graphcode` CLI. It speaks `FramedMessageIO`'s framing and - /// `DaemonProtocol`'s JSON over the unix socket `RemoteSocketForwarder` puts at the - /// canonical `~/.graphcode/graphcoded.sock` — so it needs no configuration at all, - /// though `GRAPHCODE_SOCKET` and `GRAPHCODE_SUPPORT_DIR` override the dial the same - /// way they do locally. `RemoteCLIShimTests` pins the wire contract by running this - /// very source against a Swift-decoded socket. + /// `DaemonProtocol`'s JSON over the authenticated loopback bridge whenever valid + /// state can be reached. Only an absent, invalid, or unreachable bridge falls back + /// to the explicitly available Unix socket `RemoteSocketForwarder` puts at the + /// canonical `~/.graphcode/graphcoded.sock`. `RemoteCLIShimTests` pins the wire + /// contract by running this very source against a Swift-decoded socket. public static let cliShimSource = #""" #!/usr/bin/env python3 # The remote half of the `graphcode` CLI, delivered by the Mac that launched this @@ -153,6 +241,7 @@ public enum RemoteGraphAccess { # deliberate subset: the verbs a loop needs to fan out, report back, and remember. import errno import json + import math import os import socket import struct @@ -221,6 +310,70 @@ public enum RemoteGraphAccess { return os.path.join(support, "graphcoded.sock") + def bridge_state_path(): + override = os.environ.get("GRAPHCODE_BRIDGE_STATE") + if override: + return os.path.expanduser(override) + support = os.environ.get("GRAPHCODE_SUPPORT_DIR") or "~/.graphcode" + support = os.path.expanduser(support) + if not os.path.isabs(support): + support = os.path.join(os.path.expanduser("~"), support) + return os.path.join(support, "bridge-state.json") + + + def read_bridge_state(path): + with open(path, "r", encoding="utf-8") as stream: + state = json.load(stream) + if not isinstance(state, dict): + raise ValueError("bridge state is not an object") + if state.get("schema_version") != 1 or state.get("protocol_version") != 1: + raise ValueError("unsupported bridge state") + if state.get("host") != "127.0.0.1": + raise ValueError("bridge state is not loopback-only") + try: + uuid.UUID(state["daemon_instance_id"]) + except (KeyError, TypeError, ValueError): + raise ValueError("invalid daemon instance") + generation = state.get("generation") + port = state.get("port") + capability = state.get("capability") + issued = state.get("issued_at") + expires = state.get("expires_at") + if (not isinstance(generation, int) or isinstance(generation, bool) + or generation < 1 or not isinstance(port, int) + or isinstance(port, bool) or not 1 <= port <= 65535 + or not isinstance(capability, str) + or len(capability) != 64 + or not all(c in "0123456789abcdef" for c in capability) + or not isinstance(issued, (int, float)) + or isinstance(issued, bool) + or not isinstance(expires, (int, float)) + or isinstance(expires, bool) + or not math.isfinite(issued) or not math.isfinite(expires) + or expires <= issued): + raise ValueError("invalid bridge state") + previous = state.get("previous") + if previous is not None: + if (not isinstance(previous, dict) + or not isinstance(previous.get("generation"), int) + or isinstance(previous.get("generation"), bool) + or previous["generation"] < 1 + or previous["generation"] >= generation + or not isinstance(previous.get("capability"), str) + or len(previous["capability"]) != 64 + or not all(c in "0123456789abcdef" + for c in previous["capability"]) + or not isinstance(previous.get("expires_at"), (int, float)) + or isinstance(previous.get("expires_at"), bool) + or not math.isfinite(previous["expires_at"]) + or previous["expires_at"] <= issued + or previous["expires_at"] > expires): + raise ValueError("invalid previous bridge state") + if time.time() >= expires: + raise ValueError("bridge state expired") + return state + + # Dialling is retried; nothing past the first send is. Nothing has been written when a # dial fails, so a redial cannot duplicate a mutation. It matters more here than it # does on the Mac: this socket is an ssh forward, so it disappears and comes back @@ -233,33 +386,73 @@ public enum RemoteGraphAccess { class Daemon: def __init__(self): - path = socket_path() problem = None for attempt in range(DIAL_ATTEMPTS): - if os.path.exists(path): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(10) + state_path = bridge_state_path() + if os.path.exists(state_path): + sock = None try: - sock.connect(path) + state = read_bridge_state(state_path) + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((state["host"], state["port"])) self.sock = sock + self.bridge_state = state + self.bridge = True return - except OSError as error: - sock.close() + except (OSError, ValueError) as error: + if isinstance(error, ValueError): + if os.name == "nt": + fail("bridge state is invalid or expired; retry after the " + "host reconnects", EXIT_UNAVAILABLE) + try: + os.unlink(state_path) + except OSError: + pass + if sock is not None: + sock.close() problem = error - if error.errno not in RETRYABLE_DIAL_ERRNOS: + if os.name == "nt" and getattr( + error, "errno", None) not in RETRYABLE_DIAL_ERRNOS: break - else: - problem = None + # A Unix socket is a deliberately explicit fallback. Windows hosts + # cannot use the macOS forwarding path, so a missing or broken bridge + # state remains unavailable there rather than silently using another + # transport. + if os.name != "nt": + path = socket_path() + if os.path.exists(path): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(10) + try: + sock.connect(path) + self.sock = sock + self.bridge_state = None + self.bridge = False + return + except OSError as error: + sock.close() + problem = error + if error.errno not in RETRYABLE_DIAL_ERRNOS: + break + elif not os.path.exists(state_path): + problem = None if attempt < DIAL_ATTEMPTS - 1: time.sleep(DIAL_BACKOFF[min(attempt, len(DIAL_BACKOFF) - 1)]) if problem is None: - fail("graphcoded isn't reachable at %s -- the ssh forward from the Mac " + fail("graphcoded isn't reachable -- the ssh forward from the Mac " "may be down; it returns when graphcode there next launches a loop " - "on this host." % path, EXIT_UNAVAILABLE) + "on this host.", EXIT_UNAVAILABLE) fail("couldn't reach graphcoded: %s" % problem, EXIT_UNAVAILABLE) def send(self, command): data = json.dumps(command).encode("utf-8") + if self.bridge: + data = json.dumps({ + "capability": self.bridge_state["capability"], + "generation": self.bridge_state["generation"], + "request": command, + }).encode("utf-8") self.sock.sendall(struct.pack(">I", len(data)) + data) def read_exactly(self, count): @@ -282,6 +475,9 @@ public enum RemoteGraphAccess { fail("timed out waiting for graphcoded to answer. The command may " "still have been applied -- check with `graphcode status`.", EXIT_AMBIGUOUS) + if isinstance(event, dict) and event.get("ok") is False: + error = event.get("error") or "remote bridge refused the request" + fail("remote bridge: %s" % error, EXIT_UNAVAILABLE) for key in keys: if isinstance(event, dict) and key in event: return key, event[key] diff --git a/GraphcodeKit/Sources/Sessions/RemoteSocketForwarder.swift b/GraphcodeKit/Sources/Sessions/RemoteSocketForwarder.swift index 7a265480..c3ea23b6 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteSocketForwarder.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteSocketForwarder.swift @@ -31,6 +31,10 @@ public actor RemoteSocketForwarder { /// is unreachable and why, which is more diagnosable than anything a launcher with /// no UI could do from here. public func ensureForwarding(to location: RemoteProjectLocation) { +#if os(Windows) + _ = location + return +#else let key = location.authority if let existing = forwarders[key], existing.isRunning { return } let process = Process() @@ -44,13 +48,18 @@ public actor RemoteSocketForwarder { try process.run() forwarders[key] = process } catch {} +#endif } static func forwardScript(for location: RemoteProjectLocation, localSocketPath: String) -> String { let prepare = location.sshCommandLine( - remoteCommand: "mkdir -p \"$HOME/.graphcode\" && rm -f \"$HOME/.graphcode/graphcoded.sock\"" + remoteCommand: "mkdir -p \"$HOME/.graphcode\" && rm -f" + + " \"$HOME/.graphcode/graphcoded.sock\"" + + " \"$HOME/.graphcode/bridge-state.json\"" + + " \"$HOME/.graphcode/bridge-state-generation\"" + + " \"$HOME/.graphcode/bridge-state.json.lock\"" + " && printf %s \"$HOME\"") let forward = forwardCommandLine(for: location, localSocketPath: localSocketPath) return """ @@ -68,7 +77,7 @@ public actor RemoteSocketForwarder { -> String { var argv = [ - "/usr/bin/ssh", "-N", + SSHExecutableResolver.executableURL()?.path ?? "ssh", "-N", "-o", "ExitOnForwardFailure=yes", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", ] diff --git a/GraphcodeKit/Sources/Sessions/SessionIDStore.swift b/GraphcodeKit/Sources/Sessions/SessionIDStore.swift index db087eff..1e6daaaf 100644 --- a/GraphcodeKit/Sources/Sessions/SessionIDStore.swift +++ b/GraphcodeKit/Sources/Sessions/SessionIDStore.swift @@ -77,4 +77,11 @@ public enum SessionIDStore { public static func remove(forNodeID nodeID: UUID) { try? FileManager.default.removeItem(at: file(forNodeID: nodeID)) } + + public static func nodeIDs() -> [UUID] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil) + else { return [] } + return files.compactMap { UUID(uuidString: $0.deletingPathExtension().lastPathComponent) } + } } diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 6555bba1..b697f9d3 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -196,9 +196,15 @@ public enum SessionTransplant { /// `/private` prefixes unresolved and produced the wrong directory for exactly /// those paths. static func claudeProjectSlug(forWorkingDirectory path: String) -> String { - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path - return String(resolved.map { $0.isLetter || $0.isNumber ? $0 : "-" }) + #if os(Windows) + let resolved = + URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path + #else + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let resolved = + path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path + #endif + return resolved.map { $0.isLetter || $0.isNumber ? String($0) : "-" }.joined() } private static func findClaudeTranscript(sessionID: String) -> URL? { diff --git a/GraphcodeKit/Sources/Sessions/WindowsPTYProcessSession.swift b/GraphcodeKit/Sources/Sessions/WindowsPTYProcessSession.swift new file mode 100644 index 00000000..036c2923 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/WindowsPTYProcessSession.swift @@ -0,0 +1,102 @@ +import Foundation + +#if os(Windows) + public enum PTYSessionEvent: Sendable, Equatable { + case output(String) + case terminated(succeeded: Bool) + } + + /// Pipe-backed process sessions used by Windows remote SSH launches. Remote commands + /// do not require a local terminal; keeping the same small API as the Darwin PTY + /// implementation lets the shared session launcher carry stdin state transfers without + /// duplicating its retry and delivery logic. + public final class PTYProcessSession: @unchecked Sendable { + public let id = UUID() + public let events: AsyncStream + + private let process: Process + private let input: FileHandle + private let continuation: AsyncStream.Continuation + + public enum SessionError: Error, Equatable { + case failedToLaunch + } + + public init( + executable: String = "cmd.exe", + arguments: [String] = [], + workingDirectory: String? = nil, + extraEnvironment: [String: String] = [:] + ) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let workingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + } + let inputPipe = Pipe() + let outputPipe = Pipe() + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = outputPipe + var environment = ProcessInfo.processInfo.environment + for key in environment.keys where AgentEnvironment.isInheritedAgentIdentity(key) { + environment.removeValue(forKey: key) + } + for (key, value) in extraEnvironment { + environment[key] = value + } + process.environment = environment + do { + try process.run() + } catch { + throw SessionError.failedToLaunch + } + + self.process = process + input = inputPipe.fileHandleForWriting + let (stream, continuation) = AsyncStream.makeStream() + events = stream + self.continuation = continuation + Task.detached { [weak process, weak output = outputPipe.fileHandleForReading] in + guard let output else { return } + let data = output.readDataToEndOfFile() + if !data.isEmpty { + continuation.yield(.output(String(decoding: data, as: UTF8.self))) + } + process?.waitUntilExit() + continuation.yield(.terminated(succeeded: process?.terminationStatus == 0)) + continuation.finish() + } + } + + public func sendInput(_ text: String) { + guard let data = text.data(using: .utf8) else { return } + try? input.write(contentsOf: data) + } + + public func terminate() { + if process.isRunning { + process.terminate() + } + } + + public func waitUntilFinished() async -> Bool { + await waitCollectingOutput().succeeded + } + + public func waitCollectingOutput() async -> (succeeded: Bool, output: String) { + var succeeded = false + var output = "" + for await event in events { + switch event { + case .output(let chunk): + output += chunk + case .terminated(let didSucceed): + succeeded = didSucceed + } + } + return (succeeded, output) + } + } +#endif diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index d18e6ee2..5e6b97a2 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1,5 +1,74 @@ import Foundation +#if os(Windows) + actor WindowsRemoteBridgePublicationGate { + static let defaultTimeout: Duration = .seconds(30) + + private struct Pending { + let token: UUID + let task: Task + } + + private var pending: [String: Pending] = [:] + private var latestGeneration: [String: UInt64] = [:] + + func publish( + authority: String, + generation: UInt64, + timeout: Duration = .seconds(30), + operation: @escaping @Sendable () async -> Bool + ) async -> Bool { + latestGeneration[authority] = max(latestGeneration[authority] ?? 0, generation) + let predecessor = pending[authority]?.task + let token = UUID() + let task = Task { [weak self] () -> Bool in + if let predecessor, + await Self.waitFor(predecessor, timeout: timeout) == nil + { + predecessor.cancel() + } + guard let self else { return false } + guard await isLatest(authority: authority, generation: generation) else { + await finish(authority: authority, token: token) + return true + } + let result = await operation() + await finish(authority: authority, token: token) + return result + } + pending[authority] = Pending(token: token, task: task) + return await task.value + } + + private static func waitFor( + _ task: Task, timeout: Duration + ) async -> Bool? { + await withTaskGroup(of: Bool?.self) { group in + group.addTask { await task.value } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let result = await group.next() ?? nil + if result == nil { + task.cancel() + } + group.cancelAll() + return result + } + } + + private func isLatest(authority: String, generation: UInt64) -> Bool { + latestGeneration[authority] == generation + } + + private func finish(authority: String, token: UUID) { + guard pending[authority]?.token == token else { return } + pending.removeValue(forKey: authority) + } + } +#endif + /// Starts an unattended node's session — time-based or goal-based — detached, so its /// loop runs whether or not the app is open. The daemon-side half of /// `GraphStore.ensureUnattendedSessions`. @@ -23,6 +92,52 @@ import Foundation /// shell-quotes each argument, so `claude` received the literal `$(cat …)` text as its /// prompt instead of the prompt itself. public enum ZmxSessionLauncher { + #if os(Windows) + /// One bridge owner serves every remote project in this UI process. The bridge state is + /// per authority, so multiple hosts and projects remain isolated without duplicating + /// transport setup in the session launcher. + private final class WindowsRemoteBridgeProvider: @unchecked Sendable { + private let lock = NSLock() + private var bridge: (any WindowsRemoteBridgeService)? + + init(bridge: (any WindowsRemoteBridgeService)?) { + self.bridge = bridge + } + + func get() -> (any WindowsRemoteBridgeService)? { + lock.lock() + defer { lock.unlock() } + return bridge + } + + func set(_ bridge: (any WindowsRemoteBridgeService)?) { + lock.lock() + self.bridge = bridge + lock.unlock() + } + } + + private static let windowsRemoteBridgeProvider = WindowsRemoteBridgeProvider( + bridge: try? WindowsRemoteBridge()) + + private static let windowsRemoteBridgePublicationGate = + WindowsRemoteBridgePublicationGate() + + static func setWindowsRemoteBridgeForTesting( + _ bridge: (any WindowsRemoteBridgeService)? + ) { + windowsRemoteBridgeProvider.set(bridge) + } + + public static func shutdownWindowsRemoteBridge() async { + guard let bridge = windowsRemoteBridgeProvider.get() else { return } + if let bridge = bridge as? WindowsRemoteBridge { + await bridge.shutdown() + } + windowsRemoteBridgeProvider.set(nil) + } + #endif + /// `zmx kill ` is a no-op (with a stderr note) when nothing matches, so this is /// safe for a node whose session was never started or has already exited. static func killArguments(forNode node: LoopNode) -> [String] { @@ -397,7 +512,11 @@ public enum ZmxSessionLauncher { /// Whether the node has a live session at all. Internal rather than private because /// `CopilotSessionLog` needs the same liveness gate before it trusts a log tail: a /// killed session's log still ends at whatever it was doing. - static func sessionExists(_ node: LoopNode) async -> Bool { + static func sessionExists(_ node: LoopNode, projectPath: String? = nil) async -> Bool { + if let projectPath, let remote = RemoteProjectLocation.parse(projectPath: projectPath) { + return await runRemoteRetrying(remoteStatusInvocation( + forNode: node, label: "presence", at: remote)) + } guard let session = try? PTYProcessSession( executable: ZmxLocator.binaryURL.path, @@ -406,6 +525,94 @@ public enum ZmxSessionLauncher { return await session.waitUntilFinished() } + public static func startResult( + _ node: LoopNode, projectPath: String? = nil + ) async -> Result { + guard ZmxLocator.isInstalled else { + return .failure(.unavailable("zmx is not installed")) + } + if await sessionExists(node, projectPath: projectPath) { + return .success(.attached) + } + var spawnedProcess: Process? + if node.sessionPrompt == nil || node.sessionPrompt?.isEmpty == true { + guard let executable = node.backend.executableName else { + return .failure(.unavailable("backend has no executable")) + } + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + #if os(Windows) + do { + let process = Process() + process.executableURL = URL(fileURLWithPath: ZmxLocator.binaryURL.path) + process.arguments = ["--daemon", name, executable] + if let directory = workingDirectory(forNode: node, projectPath: projectPath) { + process.currentDirectoryURL = URL(fileURLWithPath: directory) + } + try process.run() + spawnedProcess = process + } catch { + return .failure(.failed("zmx daemon launch failed: \(error)")) + } + #else + await atomicCheckOrRun( + checkArguments: existenceCheckArguments(forNode: node), + runArguments: ["run", name, "-d", executable], + zmxPath: ZmxLocator.binaryURL.path, + workingDirectory: workingDirectory(forNode: node, projectPath: projectPath)) + #endif + } else { + await start(node, projectPath: projectPath) + } + for delay in [100, 200, 400, 800, 1200] { + try? await Task.sleep(for: .milliseconds(delay)) + if await sessionExists(node, projectPath: projectPath) { + spawnedProcess = nil + return .success(.started) + } + } + if let spawnedProcess { + if spawnedProcess.isRunning { spawnedProcess.terminate() } + spawnedProcess.waitUntilExit() + } + await kill(node, projectPath: projectPath) + for delay in [100, 200, 400] { + if await sessionExists(node, projectPath: projectPath) { + await kill(node, projectPath: projectPath) + } + try? await Task.sleep(for: .milliseconds(delay)) + } + if await sessionExists(node, projectPath: projectPath) { + return .failure(.failed("zmx session appeared after startup timeout")) + } + return .failure(.failed( + "zmx session did not become live (\(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName))")) + } + + public static func terminateResult( + _ node: LoopNode, projectPath: String? = nil + ) async -> Result { + guard await sessionExists(node, projectPath: projectPath) else { + SessionIDStore.remove(forNodeID: node.id) + return .success(()) + } + + await kill(node, projectPath: projectPath) + guard !(await sessionExists(node, projectPath: projectPath)) else { + return .failure(.failed("zmx session remained after terminate")) + } + return .success(()) + } + + public static func enumerateSessionIDs() async -> [UUID] { + var live: [UUID] = [] + for id in QuickChatSessionRegistry.ids() { + if await sessionExists(LoopNode(id: id, title: "")) { + live.append(id) + } + } + return live + } + /// Kills the session behind an id that isn't a graph node — a quick chat. Public /// because chats are app-owned: no daemon deletes their sessions for them, the way /// `GraphStore` does when a loop is deleted. @@ -797,7 +1004,8 @@ public enum ZmxSessionLauncher { /// single shell costs. static func remoteEnsureInvocation( forNode node: LoopNode, at location: RemoteProjectLocation, - settings: GraphcodeSettings = GraphcodeSettingsStore.load() + settings: GraphcodeSettings = GraphcodeSettingsStore.load(), + bridgeState: RemoteBridgeWireState? = nil ) -> [String]? { guard let zmxArguments = arguments( @@ -821,7 +1029,9 @@ public enum ZmxSessionLauncher { node.backend == .claudeCode ? (PresenceHooks.remoteWriteFragment().map { $0 + "; " } ?? "") : "" let delivery = - remoteDeliveryScript(forNode: node, at: location, settings: settings) + remoteDeliveryScript( + forNode: node, at: location, settings: settings, bridgeState: bridgeState + ) .map { $0 + "; " } ?? "" let create = remoteCreateScript( forNode: node, freshRun: run, at: location, settings: settings) @@ -840,7 +1050,9 @@ public enum ZmxSessionLauncher { ? " && { " + CopilotSessionLog.remoteIDBankFragment(forNodeID: node.id) + "; }" : "" let script = "cd \(RemoteProjectLocation.shellQuoted(location.remotePath)) && { " - + deliveryFragment(delivery, ifSessionMissing: check) + + deliveryFragment( + delivery, ifSessionMissing: check, + bridgeStateGeneration: bridgeState.map(\.generation)) + "\(check) >/dev/null 2>&1\(bank) || { " + trustSeed + hooksWrite + "\(create); }; }" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) @@ -861,7 +1073,10 @@ public enum ZmxSessionLauncher { /// base64 per loop per minute once the sweep existed. The stamp splits the difference: /// a healthy tick costs one extra `zmx get` and a `cat` on the same host, and the /// delivery itself runs only when it has something new to say. - static func deliveryFragment(_ delivery: String, ifSessionMissing check: String) -> String { + static func deliveryFragment( + _ delivery: String, ifSessionMissing check: String, + bridgeStateGeneration: UInt64? = nil + ) -> String { guard !delivery.isEmpty else { return "" } let stamp = RemoteProjectLocation.shellQuoted(RemoteGraphAccess.cliShimStamp) // Tilde, unquoted, so the remote shell expands it — the same one constant the @@ -873,8 +1088,14 @@ public enum ZmxSessionLauncher { // missing session has to re-deliver whatever the stamp says: the create branch below // launches an argv naming the briefing, wake digest and prompt files, and every one // of them rides in this same fragment. + let bridgeChanged = + bridgeStateGeneration.map { + " || [ \"$(cat \(RemoteGraphAccess.bridgeStateGenerationPath) 2>/dev/null)\" != " + + RemoteProjectLocation.shellQuoted(String($0)) + " ]" + } ?? "" return "if ! \(check) >/dev/null 2>&1 " - + "|| [ \"$(cat \(stampFile) 2>/dev/null)\" != \(stamp) ]; then " + + "|| [ \"$(cat \(stampFile) 2>/dev/null)\" != \(stamp) ]" + + bridgeChanged + "; then " + delivery + "fi; " } @@ -945,8 +1166,10 @@ public enum ZmxSessionLauncher { /// because the app's *attach* delivers too, before any node exists to have memory. /// Public for exactly that caller (`GhosttyTerminalView.remoteCommand`). public static func remoteDeliveryScript( - forNode node: LoopNode?, at location: RemoteProjectLocation, settings: GraphcodeSettings + forNode node: LoopNode?, at location: RemoteProjectLocation, settings: GraphcodeSettings, + bridgeState: RemoteBridgeWireState? = nil ) -> String? { + _ = bridgeState var files = [RemoteGraphAccess.cliInstallPath: RemoteGraphAccess.cliShimSource] if settings.briefsSessionsAboutTheGraph, let text = SessionBriefing.text(projectPath: location.projectPath) @@ -980,6 +1203,18 @@ public enum ZmxSessionLauncher { receipt: (path: RemoteGraphAccess.shimStampPath, content: RemoteGraphAccess.cliShimStamp)) } + public static func remoteBridgeStateTransfer( + _ state: RemoteBridgeWireState, at location: RemoteProjectLocation + ) -> (invocation: [String], input: Data)? { + guard let data = try? JSONEncoder().encode(state) else { return nil } + let script = RemoteGraphAccess.bridgeStateInstallerScript( + length: data.count, sha256: GraphcodeSHA256.hex(data)) + return ( + location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)), + data + ) + } + /// `quotedCommand`, except that arguments naming graphcode's own remote files — /// the `~/.graphcode/…` paths `RemoteGraphAccess` mints — keep their tilde outside /// the quotes as `~/'…'`, so the *remote* login shell expands it before `zmx` @@ -1154,20 +1389,63 @@ public enum ZmxSessionLauncher { /// idempotent commands: ensure is create-only and kill is a no-op on a dead session, /// where a retried *send* could type the same message twice (its caller already has a /// staging fallback for the honest failure). - static func runRemoteRetrying(_ invocation: [String], attempts: Int = 3) async -> Bool { + static func runRemoteRetrying( + _ invocation: [String], + attempts: Int = 3, + standardInput: Data? = nil, + timeout: Duration? = nil + ) async -> Bool { RemoteProjectLocation.prepareControlSocketDirectory() + guard attempts > 0 else { return false } for attempt in 1...attempts { - if let session = try? PTYProcessSession( - executable: invocation[0], arguments: Array(invocation.dropFirst())), - await session.waitUntilFinished() - { + guard !Task.isCancelled else { return false } + guard + let session = try? PTYProcessSession( + executable: invocation[0], arguments: Array(invocation.dropFirst())) + else { return false } + if let standardInput { + session.sendInput(String(decoding: standardInput, as: UTF8.self) + "\n") + } + if await waitForRemoteProcess(session, timeout: timeout) { return true } - if attempt < attempts { try? await Task.sleep(for: .seconds(1 << (attempt - 1))) } + if attempt < attempts, !Task.isCancelled { + try? await Task.sleep(for: .seconds(1 << (attempt - 1))) + } } return false } + private static func waitForRemoteProcess( + _ session: PTYProcessSession, timeout: Duration? + ) async -> Bool { + let waiter = Task { await session.waitUntilFinished() } + return await withTaskCancellationHandler(operation: { + guard let timeout else { return await waiter.value } + let result = await withTaskGroup(of: Bool?.self) { group in + group.addTask { await waiter.value } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + if first == nil { + waiter.cancel() + session.terminate() + } + group.cancelAll() + return first + } + guard let result else { + _ = await waiter.value + return false + } + return result + }, onCancel: { + session.terminate() + }) + } + static func remoteKillInvocation( forNode node: LoopNode, at location: RemoteProjectLocation ) -> [String] { @@ -1183,15 +1461,60 @@ public enum ZmxSessionLauncher { // A dial already in flight for this node is doing this job; a second one racing it // is how two `zmx run`s land on one session (`RemoteEnsureGate`). guard let lease = await RemoteEnsureGate.shared.begin(node.id) else { return } - // The forwarded socket is what makes the delivered CLI's dial land on this Mac's - // daemon — without it the shim's commands have nowhere to go. Kept alive per host, - // not per launch; see `RemoteSocketForwarder`. - await RemoteSocketForwarder.shared.ensureForwarding(to: location) + #if os(Windows) + guard + location.port == nil + || (location.port ?? 0) > 0 && (location.port ?? 0) <= Int(UInt16.max) + else { + await RemoteEnsureGate.shared.end(node.id, token: lease) + return + } + let authorityPort = location.port.map { UInt16($0) } + let authority = WindowsSSHAuthority( + user: location.user, host: location.host, port: authorityPort) + guard + let bridge = windowsRemoteBridgeProvider.get(), + let state = try? await bridge.ensureForwarding(authority: authority) + else { + await RemoteEnsureGate.shared.end(node.id, token: lease) + return + } + let bridgeState = RemoteBridgeWireState(remoteBridgeState: state) + guard let transfer = remoteBridgeStateTransfer(bridgeState, at: location) else { + await RemoteEnsureGate.shared.end(node.id, token: lease) + return + } + let transferred = await windowsRemoteBridgePublicationGate.publish( + authority: authority.key, + generation: bridgeState.generation, + timeout: WindowsRemoteBridgePublicationGate.defaultTimeout + ) { + await runRemoteRetrying( + transfer.invocation, + standardInput: transfer.input, + timeout: WindowsRemoteBridgePublicationGate.defaultTimeout) + } + guard transferred else { + await RemoteEnsureGate.shared.end(node.id, token: lease) + return + } + #else + let bridgeState: RemoteBridgeWireState? = nil + #endif + // macOS keeps the historical Unix-socket forward alive per host. Windows instead + // established its authenticated loopback TCP bridge above; both paths leave the + // delivered shim with a local endpoint and keep transport details out of launch + // command construction. + #if !os(Windows) + await RemoteSocketForwarder.shared.ensureForwarding(to: location) + #endif // Create only, in one round-trip — see `remoteEnsureInvocation` for why the check // and the run must share a shell. A failure after the retries is the same posture // as the local path: no UI here, the node's state stays honest, opening the loop // retries. - if let ensure = remoteEnsureInvocation(forNode: node, at: location) { + if let ensure = remoteEnsureInvocation( + forNode: node, at: location, bridgeState: bridgeState + ) { _ = await runRemoteRetrying(ensure) } await RemoteEnsureGate.shared.end(node.id, token: lease) @@ -1457,12 +1780,24 @@ public enum ZmxSessionLauncher { ) async { let check = quotedCommand([zmxPath] + checkArguments) let run = quotedCommand([zmxPath] + runArguments) - let script = - logFragment.map { "\(check) >/dev/null 2>&1 || { \($0); \(run); }" } - ?? "\(check) >/dev/null 2>&1 || \(run)" + #if os(Windows) + // `logFragment` is POSIX shell — `mkdir -p`, `wc`, `printf`, `$HOME` — so it cannot + // ride inside a `cmd.exe` script. Windows ensures therefore run unlogged rather + // than with a fragment quoted into something that would not execute; the Swift-side + // `DialLog.record` is the path to route this through when it is wired up. + let script = "\(check) >NUL 2>&1 || \(run)" + let executable = "cmd.exe" + let arguments = ["/d", "/s", "/c", script] + #else + let script = + logFragment.map { "\(check) >/dev/null 2>&1 || { \($0); \(run); }" } + ?? "\(check) >/dev/null 2>&1 || \(run)" + let executable = "/bin/sh" + let arguments = ["-c", script] + #endif guard let session = try? PTYProcessSession( - executable: "/bin/zsh", arguments: ["-c", script], + executable: executable, arguments: arguments, workingDirectory: workingDirectory) else { return } _ = await session.waitUntilFinished() diff --git a/GraphcodeKit/Sources/SupportDirectory.swift b/GraphcodeKit/Sources/SupportDirectory.swift index 7db6c904..ec245361 100644 --- a/GraphcodeKit/Sources/SupportDirectory.swift +++ b/GraphcodeKit/Sources/SupportDirectory.swift @@ -1,5 +1,9 @@ import Foundation +#if os(Windows) + import WinSDK +#endif + /// The one directory graphcode keeps all of its own state in: `~/.graphcode`. /// /// Everything lives here — per-project graphs, the recents and open-projects indexes, @@ -53,18 +57,94 @@ public enum SupportDirectory { /// `~/.graphcode`, unless `GRAPHCODE_SUPPORT_DIR` says otherwise. public static var url: URL { - let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - guard let override = ProcessInfo.processInfo.environment[environmentKey], - !override.trimmingCharacters(in: .whitespaces).isEmpty - else { + url( + environment: ProcessInfo.processInfo.environment, + homeDirectory: URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)) + } + + /// Resolves an injected environment without changing the process environment. + /// + /// Foundation's `URL(fileURLWithPath:)` only recognizes POSIX absolute paths on + /// Darwin. On Windows, drive-letter and UNC paths must be classified before URL + /// construction or an override such as `C:\GraphCode` is appended to the user's + /// home directory. The override is trimmed once; Windows environment keys are + /// case-insensitive, while Darwin preserves exact-key behavior. + public static func url(environment: [String: String], homeDirectory: URL) -> URL { + let configured = configuredURL(environment: environment, homeDirectory: homeDirectory) + #if os(Windows) + return resolvedWindowsURL(configured) + #else + return configured + #endif + } + + /// Resolves an injected environment without following an existing Windows + /// junction or other reparse point. This is used when a child process must + /// retain the caller's configured path in its environment. + static func configuredURL(environment: [String: String], homeDirectory: URL) -> URL { + let home = homeDirectory + guard let value = overrideValue(in: environment) else { return home.appendingPathComponent(".graphcode", isDirectory: true) } - let expanded = (override as NSString).expandingTildeInPath - return expanded.hasPrefix("/") - ? URL(fileURLWithPath: expanded, isDirectory: true) - : home.appendingPathComponent(expanded, isDirectory: true) + + let expanded = expandTilde(value, homeDirectory: home) + guard isAbsolutePath(expanded) else { + return home.appendingPathComponent(expanded, isDirectory: true) + } + return URL(fileURLWithPath: expanded, isDirectory: true) } + #if os(Windows) + private static func resolvedWindowsURL(_ url: URL) -> URL { + var widePath = Array(url.path.utf16) + widePath.append(0) + let handle = widePath.withUnsafeBufferPointer { + CreateFileW( + $0.baseAddress, + DWORD(FILE_READ_ATTRIBUTES), + DWORD(FILE_SHARE_READ) | DWORD(FILE_SHARE_WRITE) | DWORD(FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), + nil) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + return url + } + defer { _ = CloseHandle(handle) } + + var buffer = [WCHAR](repeating: 0, count: 260) + while true { + let length = buffer.withUnsafeMutableBufferPointer { + GetFinalPathNameByHandleW( + handle, + $0.baseAddress, + DWORD($0.count), + DWORD(VOLUME_NAME_DOS)) + } + guard length > 0 else { return url } + if Int(length) < buffer.count { + let resolved = String(decoding: buffer.prefix(Int(length)), as: UTF16.self) + .replacingOccurrences(of: "/", with: "\\") + let uncPrefix = "\\\\?\\UNC\\" + if resolved.range(of: uncPrefix, options: [.caseInsensitive, .anchored]) != nil { + return URL( + fileURLWithPath: "\\\\" + String(resolved.dropFirst(uncPrefix.count)), + isDirectory: true) + } + let devicePrefix = "\\\\?\\" + if resolved.range(of: devicePrefix, options: [.caseInsensitive, .anchored]) != nil { + return URL( + fileURLWithPath: String(resolved.dropFirst(devicePrefix.count)), + isDirectory: true) + } + return URL(fileURLWithPath: resolved, isDirectory: true) + } + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + } + } + #endif + /// Where graphcode kept its state before this moved. Read only by the migration below. static var legacyURL: URL { FileManager.default @@ -83,14 +163,27 @@ public enum SupportDirectory { /// safe to run concurrently and repeatedly — hence "move only when the destination is /// entirely absent" rather than any kind of merge. public static func prepare() { + let environment = ProcessInfo.processInfo.environment + let homeDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + prepare( + environment: environment, + homeDirectory: homeDirectory, + legacy: legacyURL) + } + + /// The injectable startup path used by tests and by the process-wide entry point. + static func prepare( + environment: [String: String], + homeDirectory: URL, + legacy: URL + ) { // No migration when someone has named the directory themselves: moving the legacy // Application Support folder into `~/.graphcode.dev` would empty the real location // into a scratch one, which is the opposite of what asking for a separate directory // means. Such a directory just starts empty. - let overridden = - ProcessInfo.processInfo.environment[environmentKey]? - .trimmingCharacters(in: .whitespaces).isEmpty == false - prepare(destination: url, legacy: overridden ? url : legacyURL) + let overridden = overrideValue(in: environment) != nil + let destination = url(environment: environment, homeDirectory: homeDirectory) + prepare(destination: destination, legacy: overridden ? destination : legacy) } /// The real work, with both paths injected. @@ -129,4 +222,51 @@ public enum SupportDirectory { return false } } + + private static func overrideValue(in environment: [String: String]) -> String? { + let rawValue: String? + #if os(Windows) + if let exact = environment[environmentKey] { + rawValue = exact + } else { + rawValue = environment + .keys + .sorted() + .first(where: { $0.caseInsensitiveCompare(environmentKey) == .orderedSame }) + .flatMap { environment[$0] } + } + #else + rawValue = environment[environmentKey] + #endif + + guard let rawValue else { return nil } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func isAbsolutePath(_ path: String) -> Bool { + if path.hasPrefix("/") { + return true + } + #if os(Windows) + if path.hasPrefix("\\") { + return true + } + guard path.count >= 3 else { return false } + let characters = Array(path) + return characters[1] == ":" && (characters[2] == "\\" || characters[2] == "/") + #else + return false + #endif + } + + private static func expandTilde(_ path: String, homeDirectory: URL) -> String { + guard path == "~" || path.hasPrefix("~/") || path.hasPrefix("~\\") else { + return (path as NSString).expandingTildeInPath + } + let suffix = String(path.dropFirst()).trimmingCharacters(in: CharacterSet(charactersIn: "/\\")) + return suffix.isEmpty + ? homeDirectory.path + : homeDirectory.appendingPathComponent(suffix, isDirectory: true).path + } } diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..51e68546 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 2 +} \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..b0fab2f3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,105 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +#if os(Windows) +let graphcodeKitTarget: Target = .target( + name: "GraphcodeKit", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "GraphcodeKit/Sources", + exclude: [ + "TerminalLayoutStore.swift", + "Platform/WindowsSessionServices.swift", + "Sessions/PTYProcessSession.swift", + ], + sources: [ + "Domain", + "CLI/GraphcodeCommand.swift", + "IPC", + "Platform", + "DaemonBootstrap.swift", + "GraphExportBundle.swift", + "GraphExportBundle+ZIP.swift", + "GraphStore.swift", + "QuickChatStore.swift", + "Sessions/QuickChatSessionRegistry.swift", + "ProjectPersistence.swift", + "ProjectPersistence+Export.swift", + "ProjectRegistry.swift", + "SupportDirectory.swift", + "Sessions/MessageBus.swift", + "Sessions/NodeMemory.swift", + "GraphcodeSettingsStore.swift", + "Sessions/AgentEnvironment.swift", + "Sessions/CLISessionBackend.swift", + "Sessions/ClaudeSessionLog.swift", + "Sessions/CodexSessionLog.swift", + "Sessions/CopilotSessionLog.swift", + "Sessions/CopilotTrust.swift", + "Sessions/OpenCodePresencePlugin.swift", + "Sessions/RemoteEnsureGate.swift", + "Sessions/RemoteGraphAccess.swift", + "Sessions/RemoteSocketForwarder.swift", + "Sessions/RemoteTranscriptProbe.swift", + "Sessions/SessionIDStore.swift", + "Sessions/SessionTransplant.swift", + "Sessions/ShellPredicateEvaluator.swift", + "Sessions/SummaryBeatBuilder.swift", + "Sessions/SummaryModelWriter.swift", + "Sessions/TranscriptFreshness.swift", + "Sessions/WindowsPTYProcessSession.swift", + "Sessions/ZmxSessionLauncher.swift", + "Sessions/PresenceHooks.swift", + "Sessions/ZmxLocator.swift", + ]) +let graphcodedExclude: [String] = [] +let platformTestTargets: [Target] = [ + .testTarget( + name: "GraphcodeWindowsProductionTests", + dependencies: ["GraphcodeKit"], + path: "windows-tests") +] +#else +// The production package is also buildable on Darwin. The complete source tree +// supplies the Unix transport/session providers, while Windows-only files are +// guarded with #if os(Windows). +let graphcodeKitTarget: Target = .target( + name: "GraphcodeKit", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "GraphcodeKit/Sources") +let graphcodedExclude: [String] = [] +let platformTestTargets: [Target] = [] +#endif + +let package = Package( + name: "GraphcodeProduction", + // Only consulted by the Apple toolchain; the Windows build ignores it. Without it + // SwiftPM assumes macOS 10.13 and the shared sources fail to build there on APIs the + // Xcode app has always had, since that project sets its own far higher target. + platforms: [.macOS(.v13)], + products: [ + .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]), + .executable(name: "graphcoded", targets: ["graphcoded"]), + .executable(name: "graphcode", targets: ["graphcode"]), + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + graphcodeKitTarget, + .executableTarget( + name: "graphcoded", + dependencies: ["GraphcodeKit"], + path: "graphcoded/Sources", + exclude: graphcodedExclude), + .executableTarget( + name: "graphcode", + dependencies: ["GraphcodeKit"], + path: "graphcode-cli/Sources"), + ] + platformTestTargets) diff --git a/Tools/portable-prepare.py b/Tools/portable-prepare.py new file mode 100644 index 00000000..dedbfddb --- /dev/null +++ b/Tools/portable-prepare.py @@ -0,0 +1,29 @@ +"""Prepare the shared Swift fixture without platform-specific junctions.""" + +from pathlib import Path +import shutil + + +ROOT = Path(__file__).resolve().parents[1] +source = ROOT / "GraphcodeKit" / "Sources" / "Domain" +destination = ROOT / "investigation" / "spikes" / "swift-portable" / "Sources" / "GraphcodePortableDomain" +excluded = {"BackendCommand.swift", "RemoteProjectLocation.swift", "SessionBriefing.swift"} + +if not source.is_dir(): + raise SystemExit(f"portable source directory is missing: {source}") + +if destination.exists() or destination.is_symlink(): + if destination.is_dir() and not destination.is_symlink(): + shutil.rmtree(destination) + else: + destination.unlink() +destination.mkdir(parents=True) + +for path in source.rglob("*.swift"): + if path.name in excluded: + continue + target = destination / path.relative_to(source) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + +print(f"Prepared {destination} from {source}") diff --git a/Tools/tdd/README.md b/Tools/tdd/README.md new file mode 100644 index 00000000..7bbb8a5c --- /dev/null +++ b/Tools/tdd/README.md @@ -0,0 +1,22 @@ +# Red/green/refactor evidence + +Every feature, behavior change, extraction, and bug fix records: + +```text +RED: -> +GREEN: -> pass +REGRESSION: -> pass +``` + +The RED command must fail before implementation for the intended assertion, not because +the toolchain or fixture is missing. The integrated commit contains the green test and +implementation together; deliberately failing commits are not cherry-picked. + +Run locally: + +```powershell +pwsh Tools/tdd/Tests/TddEvidence.Tests.ps1 +pwsh Tools/tdd/Test-TddEvidence.ps1 -BodyPath +``` + +The pull-request workflow validates the same contract from the GitHub event payload. diff --git a/Tools/tdd/Test-TddEvidence.ps1 b/Tools/tdd/Test-TddEvidence.ps1 new file mode 100644 index 00000000..4efeccb9 --- /dev/null +++ b/Tools/tdd/Test-TddEvidence.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding(DefaultParameterSetName = "Body")] +param( + [Parameter(Mandatory, ParameterSetName = "Body")] + [string] $BodyPath, + [Parameter(Mandatory, ParameterSetName = "Event")] + [string] $EventPath +) + +$ErrorActionPreference = "Stop" + +if ($PSCmdlet.ParameterSetName -eq "Event") { + $event = Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json + $body = [string] $event.pull_request.body +} else { + $body = Get-Content -LiteralPath $BodyPath -Raw +} + +if ([string]::IsNullOrWhiteSpace($body)) { + throw "Pull request body is empty; RED/GREEN/REGRESSION evidence is required." +} + +$required = @("RED", "GREEN", "REGRESSION") +foreach ($label in $required) { + $match = [regex]::Match( + $body, + "(?im)^\s*${label}:\s*(?.+?)\s*$" + ) + if (-not $match.Success) { + throw "$label evidence is missing." + } + + $evidence = $match.Groups["evidence"].Value.Trim() + if ($evidence.Length -lt 8 -or + $evidence -match "<[^>]+>" -or + $evidence -match "(?i)\b(todo|tbd|n/?a)\b") { + throw "$label evidence is still a placeholder." + } + if ($evidence -notmatch "->") { + throw "$label evidence must use ' -> '." + } +} + +Write-Host "TDD evidence: PASS" +exit 0 diff --git a/Tools/tdd/Tests/TddEvidence.Tests.ps1 b/Tools/tdd/Tests/TddEvidence.Tests.ps1 new file mode 100644 index 00000000..67174620 --- /dev/null +++ b/Tools/tdd/Tests/TddEvidence.Tests.ps1 @@ -0,0 +1,53 @@ +$ErrorActionPreference = "Stop" + +$validator = Join-Path $PSScriptRoot "..\Test-TddEvidence.ps1" +if (-not (Test-Path $validator)) { + throw "RED: TDD evidence validator does not exist at $validator" +} + +$temporaryDirectory = Join-Path $env:TEMP "graphcode-tdd-$([guid]::NewGuid())" +New-Item -ItemType Directory -Force $temporaryDirectory | Out-Null +try { + $valid = Join-Path $temporaryDirectory "valid.md" + @" +## Summary +Adds a behavior. + +## Test plan + +RED: pwsh Tests/Feature.Tests.ps1 -> missing behavior assertion failed +GREEN: pwsh Tests/Feature.Tests.ps1 -> pass +REGRESSION: pwsh Tests/All.Tests.ps1 -> pass +"@ | Set-Content -LiteralPath $valid + & $validator -BodyPath $valid + if ($LASTEXITCODE -ne 0) { + throw "Valid TDD evidence was rejected" + } + + $missingRed = Join-Path $temporaryDirectory "missing-red.md" + @" +GREEN: pwsh Tests/Feature.Tests.ps1 -> pass +REGRESSION: pwsh Tests/All.Tests.ps1 -> pass +"@ | Set-Content -LiteralPath $missingRed + $pwsh = (Get-Process -Id $PID).Path + & $pwsh -NoProfile -File $validator -BodyPath $missingRed *> $null + if ($LASTEXITCODE -eq 0) { + throw "Missing RED evidence was accepted" + } + + $placeholder = Join-Path $temporaryDirectory "placeholder.md" + @" +RED: -> +GREEN: -> pass +REGRESSION: -> pass +"@ | Set-Content -LiteralPath $placeholder + & $pwsh -NoProfile -File $validator -BodyPath $placeholder *> $null + if ($LASTEXITCODE -eq 0) { + throw "Placeholder TDD evidence was accepted" + } +} finally { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force +} + +Write-Host "TddEvidence.Tests.ps1: PASS" +exit 0 diff --git a/Tools/windows/PACKAGING.md b/Tools/windows/PACKAGING.md new file mode 100644 index 00000000..2a7e2875 --- /dev/null +++ b/Tools/windows/PACKAGING.md @@ -0,0 +1,29 @@ +# Windows release packaging + +`package.ps1` produces a self-contained `GraphCode--windows-x86_64` +directory and ZIP. The bundle contains the GraphCode shell, `graphcoded`, +`graphcode`, `zmx`, Winghostty host assets, Swift runtime DLLs, pinned provider +metadata, `LICENSE`, and `THIRD-PARTY-NOTICES.txt`. + +```powershell +pwsh Tools/windows/package.ps1 -Command Build ` + -InputDirectory .build/windows/release ` + -Version 1.0.0 +pwsh Tools/windows/package.ps1 -Command Verify ` + -Package .build/windows/packages/GraphCode-1.0.0-windows-x86_64.zip +pwsh Tools/windows/package.ps1 -Command Install ` + -Package .build/windows/packages/GraphCode-1.0.0-windows-x86_64 +``` + +The ZIP contains one top-level `GraphCode` directory. Installation verifies the +complete manifest and provider provenance before copying anything, then stages +and swaps atomically. The scheduled task is created and run; the exact installed +daemon endpoint must become reachable or the previous installation is restored. +The daemon's actual `%USERPROFILE%\.graphcode` data directory is preserved by +uninstall unless `-RemoveUserData` is explicitly requested. + +Unsigned artifacts are explicitly marked `UNSIGNED (development artifact; not +code signed)` in `metadata.json` and `SIGNING.txt`. Signing is opt-in: +`-SignCertificate ` requires `signtool.exe` and fails if signing +cannot be completed. The script never reports a signed artifact without that +input. diff --git a/Tools/windows/Stub-Daemon.ps1 b/Tools/windows/Stub-Daemon.ps1 new file mode 100644 index 00000000..476c4399 --- /dev/null +++ b/Tools/windows/Stub-Daemon.ps1 @@ -0,0 +1,169 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PipeName, + [Parameter(Mandatory)] + [string] $ResultPath, + [switch] $NonReading +) + +$ErrorActionPreference = "Stop" +$utf8 = [Text.Encoding]::UTF8 +$seenRequests = [Collections.Generic.HashSet[string]]::new() +$seenResponses = [Collections.Generic.HashSet[string]]::new() +$requestCommands = @{} +$connectionCount = 0 +$subscriptionSeen = $false +$graphSent = $false +$busyObserved = $false +$seenCommands = [Collections.Generic.List[string]]::new() +$errorMessage = $null + +$nodeA = if ($env:GRAPHCODE_STUB_NODE_A) { $env:GRAPHCODE_STUB_NODE_A } else { "11111111-1111-4111-8111-111111111111" } +$nodeB = if ($env:GRAPHCODE_STUB_NODE_B) { $env:GRAPHCODE_STUB_NODE_B } else { "22222222-2222-4222-8222-222222222222" } +$graphEvent = '{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"stub-graph","project":{"path":"graphcode://stub/project","name":"Stub project","remote":false},"nodes":[{"id":"' + $nodeA + '","title":"Stub node A","loopType":"turnBased","state":"running","activity":"stub","presence":{"presence":"busy","confidence":"reported"}},{"id":"' + $nodeB + '","title":"Stub node B","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}}' +$recentProjects = '{"version":2,"kind":"response","requestID":"{0}","event":{"recentProjectsListed":[{"path":"graphcode://stub/project","name":"Stub project","remote":false}]}}' +$quickChats = '{"version":2,"kind":"response","requestID":"{0}","event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"Stub quick chat","backend":"claudeCode","createdAt":0,"activity":{"sequence":1,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}},{"id":"44444444-4444-4444-8444-444444444444","title":"Review notes","backend":"copilot","createdAt":1,"activity":null}]}}' +$success = '{"version":2,"kind":"response","requestID":"{0}","success":true}' +$hello = '{"version":2,"kind":"hello","supportedVersions":[1,2],"selectedVersion":2}' + +function Read-Exact([IO.Stream] $stream, [int] $length) { + $buffer = [byte[]]::new($length) + $offset = 0 + while ($offset -lt $length) { + $read = $stream.Read($buffer, $offset, $length - $offset) + if ($read -le 0) { return $null } + $offset += $read + } + return $buffer +} + +function Send-Frame([IO.Stream] $stream, [string] $json, [switch] $Fragment) { + $payload = $utf8.GetBytes($json) + $length = $payload.Length + $header = [byte[]] @( + [byte](($length -shr 24) -band 0xff), + [byte](($length -shr 16) -band 0xff), + [byte](($length -shr 8) -band 0xff), + [byte]($length -band 0xff) + ) + try { + if ($Fragment) { + $stream.Write($header, 0, 2) + $stream.Flush() + Start-Sleep -Milliseconds 20 + $stream.Write($header, 2, 2) + $stream.Flush() + for ($offset = 0; $offset -lt $payload.Length; $offset += 7) { + $count = [Math]::Min(7, $payload.Length - $offset) + $stream.Write($payload, $offset, $count) + $stream.Flush() + Start-Sleep -Milliseconds 5 + } + return $true + } + $frame = [byte[]]::new(4 + $payload.Length) + [Array]::Copy($header, 0, $frame, 0, 4) + [Array]::Copy($payload, 0, $frame, 4, $payload.Length) + $stream.Write($frame, 0, $frame.Length) + $stream.Flush() + return $true + } catch [IO.IOException] { + $script:errorMessage = $_.Exception.Message + return $false + } catch [System.Exception] { + $script:errorMessage = $_.Exception.Message + return $false + } +} + +function Write-Result { + $result = [ordered]@{ + protocolConnected = $connectionCount -gt 0 + correlatedRequests = $seenRequests.Count -ge 2 -and + (@($seenRequests | Where-Object { -not $seenResponses.Contains($_) }).Count -eq 0) + requestCount = $seenRequests.Count + unansweredRequests = @($seenRequests | Where-Object { -not $seenResponses.Contains($_) }) + unansweredCommands = @($seenRequests | Where-Object { + -not $seenResponses.Contains($_) + } | ForEach-Object { $requestCommands[$_] }) + commands = @($seenCommands) + error = $errorMessage + subscriptionSeen = $subscriptionSeen + reconnectObserved = $connectionCount -ge 2 + graphSent = $graphSent + busyObserved = $busyObserved + } + $result | ConvertTo-Json -Compress | Set-Content -LiteralPath $ResultPath -NoNewline +} + +try { + while ($connectionCount -lt 32) { + $server = [IO.Pipes.NamedPipeServerStream]::new( + $PipeName, + [IO.Pipes.PipeDirection]::InOut, + 1, + [IO.Pipes.PipeTransmissionMode]::Byte, + [IO.Pipes.PipeOptions]::None + ) + try { + $server.WaitForConnection() + $connectionCount++ + $graphSentOnConnection = $false + if ($NonReading) { + $busyObserved = $true + Write-Result + Start-Sleep -Seconds 10 + continue + } + while ($server.IsConnected) { + $header = Read-Exact $server 4 + if ($null -eq $header) { break } + $length = ([int]$header[0] -shl 24) -bor + ([int]$header[1] -shl 16) -bor + ([int]$header[2] -shl 8) -bor [int]$header[3] + if ($length -lt 0 -or $length -gt 2097152) { break } + $payload = Read-Exact $server $length + if ($null -eq $payload) { break } + $frame = $utf8.GetString($payload) | ConvertFrom-Json + if ($frame.kind -eq "hello") { + if ($frame.subscription -and @($frame.subscription.projectPaths).Count -gt 0) { + $subscriptionSeen = $true + } + if (-not (Send-Frame $server $hello)) { break } + continue + } + if ($frame.kind -ne "request" -or [string]::IsNullOrEmpty($frame.requestID)) { + break + } + [void] $seenRequests.Add([string]$frame.requestID) + $commandName = $frame.command.PSObject.Properties.Name | Select-Object -First 1 + $requestCommands[[string]$frame.requestID] = [string]$commandName + if ($commandName) { $seenCommands.Add([string]$commandName) } + $response = if ($commandName -eq "listRecentProjects") { + $recentProjects.Replace("{0}", [string]$frame.requestID) + } elseif ($commandName -eq "listQuickChats") { + $quickChats.Replace("{0}", [string]$frame.requestID) + } else { + $success.Replace("{0}", [string]$frame.requestID) + } + if (-not (Send-Frame $server $response)) { break } + [void] $seenResponses.Add([string]$frame.requestID) + if ($commandName -eq "listRecentProjects" -and -not $graphSentOnConnection) { + if (-not (Send-Frame $server $graphEvent)) { break } + $graphSent = $true + $graphSentOnConnection = $true + } + Write-Result + } + } finally { + $server.Dispose() + } + Write-Result + } +} finally { + if ($Error.Count -gt 0) { + $errorMessage = ($Error[0] | Out-String).Trim() + } + Write-Result +} diff --git a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 new file mode 100644 index 00000000..e4b8125f --- /dev/null +++ b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 @@ -0,0 +1,256 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Executable +) + +$ErrorActionPreference = "Stop" +$script:WM_QUIT = 0x0012 +$daemonExecutable = Join-Path (Split-Path -Parent $Executable) "graphcoded.exe" +$cliExecutable = Join-Path (Split-Path -Parent $Executable) "graphcode.exe" +$supportDirectory = Join-Path (Split-Path -Parent $Executable) ".handoff-live-$PID" +$daemonPipe = "\\.\pipe\graphcode-handoff-live-$PID" + +if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { + throw "shell executable is missing: $Executable" +} +foreach ($path in @($daemonExecutable, $cliExecutable)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "handoff live test requires sibling $(Split-Path -Leaf $path)" + } +} + +if (-not ("GraphCodeDaemonHandoffNative" -as [type])) { + Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class GraphCodeDaemonHandoffNative { + public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr GetProp(IntPtr hwnd, string name); + [DllImport("user32.dll")] + public static extern bool PostThreadMessage(uint threadId, uint message, IntPtr wParam, IntPtr lParam); +} +"@ +} + +function Start-HandoffShell( + [string] $userName, + [string] $daemonStatePath +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Executable + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.WorkingDirectory = Split-Path -Parent $Executable + [void] $startInfo.Environment.Remove("GRAPHCODE_DAEMON_STARTUP_EVENT") + [void] $startInfo.Environment.Remove("GRAPHCODE_DAEMON_HANDOFF_READY_EVENT") + [void] $startInfo.Environment.Remove("GRAPHCODE_DAEMON_SHUTDOWN_EVENT") + $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory + $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe + $startInfo.Environment["GRAPHCODE_SHELL_REQUIRE_DAEMON"] = "0" + $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_STATE"] = $daemonStatePath + $startInfo.Environment["GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"] = "1" + $startInfo.Environment["USERNAME"] = $userName + $startInfo.Environment["USER"] = $userName + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "could not start shell $userName" + } + [void] $process.Handle + return $process +} + +function Get-DaemonChildren([int[]] $parentIds) { + $expected = [IO.Path]::GetFullPath($daemonExecutable) + return @( + Get-CimInstance Win32_Process -ErrorAction Stop | + Where-Object { + $_.Name -ieq "graphcoded.exe" -and + $parentIds -contains [int]$_.ParentProcessId -and + $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected + } + ) +} + +function Get-ShellThread([int] $processId) { + $script:handoffThread = [uint32]0 + $callback = [GraphCodeDaemonHandoffNative+EnumWindowsProc]{ + param($hwnd, $unused) + [uint32]$owner = 0 + [uint32]$thread = [GraphCodeDaemonHandoffNative]::GetWindowThreadProcessId( + $hwnd, [ref]$owner) + if ($owner -eq $processId) { + $script:handoffThread = $thread + return $false + } + return $true + } + [void][GraphCodeDaemonHandoffNative]::EnumWindows($callback, [IntPtr]::Zero) + return $script:handoffThread +} + +function Get-ShellSupervisorState([int] $processId) { + $script:handoffWindow = [IntPtr]::Zero + $callback = [GraphCodeDaemonHandoffNative+EnumWindowsProc]{ + param($hwnd, $unused) + [uint32]$owner = 0 + [void][GraphCodeDaemonHandoffNative]::GetWindowThreadProcessId($hwnd, [ref]$owner) + if ($owner -eq $processId) { + $script:handoffWindow = $hwnd + return $false + } + return $true + } + [void][GraphCodeDaemonHandoffNative]::EnumWindows($callback, [IntPtr]::Zero) + if ($script:handoffWindow -eq [IntPtr]::Zero) { return 0 } + return [GraphCodeDaemonHandoffNative]::GetProp( + $script:handoffWindow, "GraphCode.Windows.DaemonSupervisorState").ToInt64() +} + +function Stop-ShellNormally([Diagnostics.Process] $process, [string] $role) { + for ($i = 0; $i -lt 80; $i++) { + $thread = Get-ShellThread $process.Id + if ($thread -ne 0) { + if (-not [GraphCodeDaemonHandoffNative]::PostThreadMessage( + $thread, $script:WM_QUIT, [IntPtr]::Zero, [IntPtr]::Zero)) { + throw "could not post WM_QUIT to $role shell" + } + if (-not $process.WaitForExit(7000)) { + throw "$role shell did not exit normally" + } + return + } + Start-Sleep -Milliseconds 100 + } + throw "$role shell did not create a UI message queue" +} + +function Assert-EndpointReachable { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $cliExecutable + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardError = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.WorkingDirectory = Split-Path -Parent $Executable + $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory + $startInfo.Environment["GRAPHCODE_DAEMON_PIPE"] = $daemonPipe + [void] $startInfo.ArgumentList.Add("projects") + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start() -or -not $process.WaitForExit(7000)) { + throw "daemon endpoint was not reachable through graphcode.exe" + } + $stderr = $process.StandardError.ReadToEnd() + if ($process.ExitCode -ne 0) { + throw "daemon endpoint rejected graphcode.exe: $stderr" + } + $process.Dispose() +} + +$shellA = $null +$shellB = $null +$daemonProcess = $null +try { + New-Item -ItemType Directory -Force -Path $supportDirectory | Out-Null + $daemonStateA = Join-Path $supportDirectory "daemon-a.state" + $daemonStateB = Join-Path $supportDirectory "daemon-b.state" + $shellA = Start-HandoffShell ` + "graphcode-handoff-a-$PID" $daemonStateA + $shellB = Start-HandoffShell ` + "graphcode-handoff-b-$PID" $daemonStateB + $parents = @($shellA.Id, $shellB.Id) + $children = @() + $selectedChild = $null + for ($i = 0; $i -lt 100; $i++) { + $children = @(Get-DaemonChildren $parents) + if ($children.Count -eq 1) { + $candidate = Get-Process -Id $children[0].ProcessId -ErrorAction SilentlyContinue + if ($candidate) { + $daemonProcess = $candidate + $selectedChild = $children[0] + break + } + $children = @() + } + if ($children.Count -gt 1) { + throw "concurrent shells spawned $($children.Count) graphcoded children" + } + Start-Sleep -Milliseconds 100 + } + if (-not $daemonProcess -or -not $selectedChild) { + $states = @($shellA, $shellB | ForEach-Object { + $_.Refresh() + "pid=$($_.Id), exited=$($_.HasExited), exitCode=$( + if ($_.HasExited) { $_.ExitCode } else { '' })" + }) -join "; " + $daemonStates = @($daemonStateA, $daemonStateB | + ForEach-Object { + "$(Split-Path -Leaf $_)=$(if (Test-Path -LiteralPath $_) { + Get-Content -LiteralPath $_ -Raw + } else { '' })" + }) -join "; " + throw ( + "concurrent shells did not spawn exactly one graphcoded child: $states; $daemonStates; " + + "shellStateA=$(Get-ShellSupervisorState $shellA.Id), shellStateB=$(Get-ShellSupervisorState $shellB.Id)" + ) + } + $owner = if ($selectedChild.ParentProcessId -eq $shellA.Id) { $shellA } else { $shellB } + $contender = if ($owner.Id -eq $shellA.Id) { $shellB } else { $shellA } + for ($i = 0; $i -lt 80; $i++) { + if ((Get-ShellSupervisorState $owner.Id) -eq 1 -and + (Get-ShellSupervisorState $contender.Id) -eq 2) { + break + } + Start-Sleep -Milliseconds 100 + } + $ownerState = Get-ShellSupervisorState $owner.Id + $contenderState = Get-ShellSupervisorState $contender.Id + if ($ownerState -ne 1 -or $contenderState -ne 2) { + throw ( + "shell ownership classification was incorrect: owner=$ownerState, contender=$contenderState, " + + "daemonA=$(if (Test-Path $daemonStateA) { Get-Content $daemonStateA -Raw } else { '' }), " + + "daemonB=$(if (Test-Path $daemonStateB) { Get-Content $daemonStateB -Raw } else { '' })" + ) + } + Assert-EndpointReachable + + Stop-ShellNormally $contender "non-owning" + Start-Sleep -Milliseconds 250 + $daemonProcess.Refresh() + if ($daemonProcess.HasExited) { + throw "non-owning shell exit stopped the externally owned daemon" + } + + Stop-ShellNormally $owner "owning" + if (-not $daemonProcess.WaitForExit(7000)) { + throw "owning shell exit did not stop its graphcoded child" + } + Write-Output "Concurrent two-shell daemon handoff: PASS" +} finally { + foreach ($process in @($shellA, $shellB)) { + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + if ($process) { $process.Dispose() } + } + if ($daemonProcess -and -not $daemonProcess.HasExited) { + Stop-Process -Id $daemonProcess.Id -Force -ErrorAction SilentlyContinue + } + $expectedDaemonPath = [IO.Path]::GetFullPath($daemonExecutable) + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expectedDaemonPath + } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Remove-Item -LiteralPath $supportDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Tools/windows/Tests/EnvironmentFixture.ps1 b/Tools/windows/Tests/EnvironmentFixture.ps1 new file mode 100644 index 00000000..67e85bbe --- /dev/null +++ b/Tools/windows/Tests/EnvironmentFixture.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$pipeName = "graphcode-hardening-$PID" +$server = [IO.Pipes.NamedPipeServerStream]::new( + $pipeName, + [IO.Pipes.PipeDirection]::InOut, + 1, + [IO.Pipes.PipeTransmissionMode]::Byte, + [IO.Pipes.PipeOptions]::Asynchronous) +$client = [IO.Pipes.NamedPipeClientStream]::new(".", $pipeName, [IO.Pipes.PipeDirection]::InOut) +$payload = [Text.Encoding]::UTF8.GetBytes(("环境-😀-" * 128)) +$buffer = [byte[]]::new($payload.Length) +$dimensions = @( + [ordered]@{ + name = "gpu-context-display" + status = "skip" + reason = "Requires an owned physical WGL/GPU/display harness; deterministic Windows runner has no display contract." + checks = @("context-loss", "resize", "minimize", "display-change") + }, + [ordered]@{ + name = "dpi-multimonitor" + status = "skip" + reason = "Requires two physical monitors and an owned DPI-switch harness." + checks = @("dpi-change", "monitor-move", "minimize-restore") + }, + [ordered]@{ + name = "ime-unicode-clipboard" + status = "skip" + reason = "IME and Win32 clipboard require an interactive desktop harness; Unicode round-trip is validated separately." + checks = @("ime-composition", "clipboard", "unicode") + }, + [ordered]@{ + name = "uia-screen-reader" + status = "skip" + reason = "Requires an installed screen reader/UIA automation harness." + checks = @("uia-tree", "focus", "announcements") + }, + [ordered]@{ + name = "acl-user-isolation" + status = "skip" + reason = "Cross-user ACL isolation requires a second local account and protected runner credentials." + checks = @("pipe-acl", "support-directory", "task-principal") + }, + [ordered]@{ + name = "login-reboot" + status = "skip" + reason = "Requires an owned reboot-capable runner and login restoration harness." + checks = @("startup", "login", "reboot-restore") + }, + [ordered]@{ + name = "external-ssh-multihost" + status = "skip" + reason = "Requires authenticated GRAPHCODE_REMOTE_E2E_TARGETS and multiple POSIX hosts." + checks = @("multi-host", "reconnect", "remote-reboot") + } +) +try { + $accept = $server.WaitForConnectionAsync() + $client.Connect(5000) + $accept.GetAwaiter().GetResult() + $read = 0 + $readTask = $server.ReadAsync($buffer, 0, $buffer.Length) + $client.Write($payload, 0, $payload.Length) + $client.Flush() + while (-not $readTask.Wait(5000)) { + throw "Named Pipe read exceeded 5 seconds" + } + $read = $readTask.GetAwaiter().GetResult() + if (-not [Linq.Enumerable]::SequenceEqual($payload, $buffer)) { + throw "Named Pipe UTF-8 payload mismatch" + } + Write-Output "ENVIRONMENT named-pipe: PASS (bytes=$read; timeout=5s)" + Write-Output "ENVIRONMENT local-user: PASS (current-user fixture; no broad ACL requested)" + $result = [ordered]@{ + schemaVersion = 1 + fixture = "EnvironmentFixture" + namedPipe = [ordered]@{ status = "pass"; bytes = $read; timeoutSeconds = 5 } + unicode = [ordered]@{ status = "pass"; bytes = $payload.Length; value = "环境-😀-" } + dimensions = $dimensions + } + Write-Output ("ENVIRONMENT_RESULT_JSON=" + ($result | ConvertTo-Json -Depth 8 -Compress)) +} finally { + $client.Dispose() + $server.Dispose() +} +exit 0 diff --git a/Tools/windows/Tests/Hardening.Tests.ps1 b/Tools/windows/Tests/Hardening.Tests.ps1 new file mode 100644 index 00000000..1c7241f1 --- /dev/null +++ b/Tools/windows/Tests/Hardening.Tests.ps1 @@ -0,0 +1,744 @@ +[CmdletBinding()] +param( + [switch] $Environment, + [switch] $SchemaOnly, + [switch] $SkipTrayLive, + [int] $Run = 0 +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$pwsh = (Get-Command pwsh.exe).Source +$fixtureRoot = Join-Path $repoRoot ".build\windows-hardening-$PID" +$fixture = Join-Path $fixtureRoot "fixture.ps1" +$results = [System.Collections.Generic.List[object]]::new() +$realProductSamples = [System.Collections.Generic.List[object]]::new() + +function Assert-True([bool] $condition, [string] $message) { + if (-not $condition) { throw "Hardening failure: $message" } +} + +function Test-ZmxSessionReachable([string] $zmx, [string] $name) { + & $zmx get $name *> $null + return $LASTEXITCODE -eq 0 +} + +function Select-EquivalentProductSample([object[]] $samples) { + $roleSets = @($samples | ForEach-Object { + [Collections.Generic.HashSet[string]]::new( + @($_.processes | ForEach-Object role), + [StringComparer]::OrdinalIgnoreCase) + }) + if ($roleSets.Count -eq 0) { return @() } + $roles = @($roleSets[0]) + foreach ($set in $roleSets | Select-Object -Skip 1) { + $roles = @($roles | Where-Object { $set.Contains($_) }) + } + @($samples | ForEach-Object { + $details = @($_.processes | Where-Object { $roles -contains $_.role }) + [pscustomobject]@{ + processes = $details + privateBytes = [int64](($details | Measure-Object privateBytes -Sum).Sum) + handles = [int64](($details | Measure-Object handles -Sum).Sum) + roles = @($roles) + } + }) +} + +function Invoke-Fixture([string[]] $arguments) { + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $pwsh + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.ArgumentList.Add("-NoProfile") + $info.ArgumentList.Add("-File") + $info.ArgumentList.Add($fixture) + foreach ($argument in $arguments) { $info.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $info + $started = [DateTime]::UtcNow + Assert-True $process.Start() "fixture process did not start" + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + Assert-True $process.WaitForExit(15000) "fixture exceeded 15 second recovery ceiling" + $elapsed = ([DateTime]::UtcNow - $started).TotalSeconds + $exitCode = $process.ExitCode + $process.Close() + $process.Dispose() + [pscustomobject]@{ + ExitCode = $exitCode + Stdout = $stdout.GetAwaiter().GetResult() + Stderr = $stderr.GetAwaiter().GetResult() + Elapsed = $elapsed + } +} + +function Assert-EnvironmentResult([string[]] $output) { + $line = @($output | Where-Object { $_ -like "ENVIRONMENT_RESULT_JSON=*" }) | + Select-Object -Last 1 + Assert-True ($line.Count -gt 0) "environment harness did not emit structured JSON" + $json = ($line -replace "^ENVIRONMENT_RESULT_JSON=", "") | ConvertFrom-Json + Assert-True ($json.schemaVersion -eq 1) "environment result schema is unsupported" + Assert-True ($json.namedPipe.status -eq "pass") "named pipe result was not pass" + $required = @( + "gpu-context-display", + "dpi-multimonitor", + "ime-unicode-clipboard", + "uia-screen-reader", + "acl-user-isolation", + "login-reboot", + "external-ssh-multihost" + ) + foreach ($name in $required) { + $dimension = @($json.dimensions | Where-Object name -eq $name) + Assert-True ($dimension.Count -eq 1) "mandatory environment dimension missing: $name" + Assert-True ($dimension[0].status -in @("pass", "skip")) ` + "invalid environment status for $name" + if ($dimension[0].status -eq "skip") { + Assert-True (-not [string]::IsNullOrWhiteSpace($dimension[0].reason)) ` + "environment skip has no reason: $name" + } + Write-Host ("ENVIRONMENT dimension {0}: {1} ({2})" -f + $name, $dimension[0].status, $dimension[0].reason) + } + return $json +} + +function Get-ResourceSample { + $process = Get-Process -Id $PID + [pscustomobject]@{ + privateBytes = [int64] $process.PrivateMemorySize64 + handles = [int64] $process.HandleCount + } +} + +function Get-ProductResourceSample([string[]] $roots) { + $processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $matched = [System.Collections.Generic.List[object]]::new() + foreach ($process in $processes) { + $pathMatch = $false + if ($process.ExecutablePath) { + foreach ($root in $roots) { + if ([IO.Path]::GetFullPath($process.ExecutablePath) -ieq [IO.Path]::GetFullPath($root) -or + [IO.Path]::GetFullPath($process.ExecutablePath).StartsWith( + ([IO.Path]::GetFullPath((Split-Path $root)) + "\"), [StringComparison]::OrdinalIgnoreCase)) { + $pathMatch = $true + break + } + } + } + if ($pathMatch) { $matched.Add($process) } + } + $known = [Collections.Generic.HashSet[int]]::new() + foreach ($process in @($matched)) { [void] $known.Add([int]$process.ProcessId) } + $changed = $true + while ($changed) { + $changed = $false + foreach ($process in $processes) { + if (-not $known.Contains([int]$process.ProcessId) -and + $known.Contains([int]$process.ParentProcessId)) { + [void] $known.Add([int]$process.ProcessId) + $matched.Add($process) + $changed = $true + } + } + } + $details = @($known | ForEach-Object { + $sample = Get-Process -Id $_ -ErrorAction SilentlyContinue + if ($sample) { + [pscustomobject]@{ + pid = $_ + name = $sample.ProcessName + role = if ($sample.ProcessName -match "graphcoded") { "graphcoded" } + elseif ($sample.ProcessName -match "graphcode-windows") { "graphcode-windows" } + elseif ($sample.ProcessName -match "zmx") { "zmx" } + elseif ($sample.ProcessName -match "winghostty") { "winghostty" } + else { "product-child" } + privateBytes = [int64]$sample.PrivateMemorySize64 + handles = [int64]$sample.HandleCount + } + } + }) + [pscustomobject]@{ + processes = $details + privateBytes = [int64](($details | Measure-Object privateBytes -Sum).Sum) + handles = [int64](($details | Measure-Object handles -Sum).Sum) + } +} + +function Find-Bytes([byte[]] $haystack, [byte[]] $needle, [int] $start = 0) { + for ($index = $start; $index -le $haystack.Length - $needle.Length; $index++) { + $match = $true + for ($offset = 0; $offset -lt $needle.Length; $offset++) { + if ($haystack[$index + $offset] -ne $needle[$offset]) { + $match = $false + break + } + } + if ($match) { return $index } + } + return -1 +} + +function Select-NewProductResourceSample([object] $sample, [int[]] $baselinePids) { + $details = @($sample.processes | Where-Object { $baselinePids -notcontains $_.pid }) + [pscustomobject]@{ + processes = $details + privateBytes = [int64](($details | Measure-Object privateBytes -Sum).Sum) + handles = [int64](($details | Measure-Object handles -Sum).Sum) + } +} + +function Stop-OwnedSessionProcessTree([string] $sessionName) { + $all = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $ids = [Collections.Generic.HashSet[int]]::new() + foreach ($process in $all) { + if ($process.CommandLine -and + $process.CommandLine -match [regex]::Escape($sessionName)) { + [void] $ids.Add([int]$process.ProcessId) + } + } + $changed = $true + while ($changed) { + $changed = $false + foreach ($process in $all) { + if (-not $ids.Contains([int]$process.ProcessId) -and + $ids.Contains([int]$process.ParentProcessId)) { + [void] $ids.Add([int]$process.ProcessId) + $changed = $true + } + } + } + foreach ($id in $ids) { + if (Get-Process -Id $id -ErrorAction SilentlyContinue) { + Stop-Process -Id $id -Force -ErrorAction SilentlyContinue + } + } +} + +function Read-CapturedBytes([string] $path) { + $stream = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) + try { + $memory = [IO.MemoryStream]::new() + try { + $stream.CopyTo($memory) + return $memory.ToArray() + } finally { + $memory.Dispose() + } + } finally { + $stream.Dispose() + } +} + +function Invoke-RealMatrix { + $wing = $env:GRAPHCODE_WINGHOSTTY_ROOT + $zmxRoot = $env:GRAPHCODE_ZMX_ROOT + $zig0152 = $env:GRAPHCODE_ZIG0152 + $zig0160 = $env:GRAPHCODE_ZIG0160 + Assert-True ($wing -and $zmxRoot -and $zig0152 -and $zig0160) ` + "real hardening requires pinned provider and Zig paths" + $zmx = Join-Path $zmxRoot "zig-out\bin\zmx.exe" + $release = Join-Path $repoRoot ".build\windows\release-artifact" + if (-not (Test-Path -LiteralPath (Join-Path $release "graphcoded.exe"))) { + $release = Join-Path $repoRoot ".build\x86_64-unknown-windows-msvc\release" + } + $graphcoded = Join-Path $release "graphcoded.exe" + $graphcode = Join-Path $release "graphcode.exe" + $shell = Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe" + foreach ($binary in @($zmx, $graphcoded, $graphcode, $shell)) { + Assert-True (Test-Path -LiteralPath $binary) "real hardening binary missing: $binary" + } + $shellVersion = (& $shell --version 2>$null | Select-Object -First 1).Trim() + Assert-True ([bool] $shellVersion) "real GraphCode shell did not report a version" + + $support = Join-Path $repoRoot ".build\hardening-real-$PID" + New-Item -ItemType Directory -Force $support | Out-Null + $oldSupport = $env:GRAPHCODE_SUPPORT_DIR + $daemon = $null + $daemonId = $null + $attach = $null + $attachId = $null + $captureStream = $null + $outputDaemon = $null + $outputDaemonId = $null + $outputDaemonOut = $null + $outputDaemonErr = $null + $sessionName = $null + $productRoots = @($graphcoded, $shell, $zmx, $wing) + $productBefore = Get-ProductResourceSample $productRoots + $productBaselinePids = @($productBefore.processes | ForEach-Object pid) + Write-Output ("HARDENING product-resources-before: processes=$($productBefore.processes.Count); " + + "handles=$($productBefore.handles); private MiB=$([Math]::Round($productBefore.privateBytes / 1MB, 1))") + try { + $env:GRAPHCODE_SUPPORT_DIR = $support + $daemon = Start-Process -FilePath $graphcoded -WorkingDirectory ` + (Split-Path $graphcoded) -PassThru -WindowStyle Hidden + $daemonId = $daemon.Id + Start-Sleep -Milliseconds 1200 + Assert-True (-not $daemon.HasExited) "real graphcoded exited during startup" + & $graphcode projects *> $null + Assert-True ($LASTEXITCODE -eq 0) "real graphcode CLI could not reach graphcoded" + Stop-Process -Id $daemonId -Force -ErrorAction Stop + [void] $daemon.WaitForExit(1000) + $daemon.Dispose() + $daemon = Start-Process -FilePath $graphcoded -WorkingDirectory ` + (Split-Path $graphcoded) -PassThru -WindowStyle Hidden + $daemonId = $daemon.Id + Start-Sleep -Milliseconds 1200 + Assert-True (-not $daemon.HasExited) "real graphcoded did not recover after restart" + & $graphcode projects *> $null + Assert-True ($LASTEXITCODE -eq 0) "graphcode CLI failed after daemon restart" + + $before = Get-ResourceSample + $terminalGate = Join-Path $repoRoot "Tools\windows\terminal-gate.ps1" + & $pwsh -NoProfile -File $terminalGate -WinghosttyRoot $wing -ZmxRoot $zmxRoot ` + -Zig0152 $zig0152 -Zig0160 $zig0160 -SkipBuild -Stress + Assert-True ($LASTEXITCODE -eq 0) "real zmx/ConPTY terminal matrix failed" + $shellScript = Join-Path $repoRoot "Tools\windows\windows-shell.ps1" + & $pwsh -NoProfile -File $shellScript -WinghosttyRoot $wing -ZmxRoot $zmxRoot ` + -Zig0152 $zig0152 -Zig0160 $zig0160 -SkipBuild -Stress -UseStubDaemon ` + -SkipTrayLive:$SkipTrayLive -Version $shellVersion + Assert-True ($LASTEXITCODE -eq 0) "real GraphCode shell matrix failed" + $productAfterWorkload = Get-ProductResourceSample $productRoots + $productNewWorkload = Select-NewProductResourceSample $productAfterWorkload $productBaselinePids + $realProductSamples.Add($productNewWorkload) + Write-Output ("PRODUCT_RESOURCE_METRICS_JSON=" + ([ordered]@{ + snapshotId = "$PID-graphcoded-tree" + phase = "graphcoded:active-workload" + processes = $productNewWorkload.processes + } | ConvertTo-Json -Compress -Depth 6)) + Assert-True (($productNewWorkload.processes | Where-Object privateBytes -gt 512MB).Count -eq 0) ` + "a product process exceeded 512 MiB private memory" + Assert-True (($productNewWorkload.processes | Where-Object handles -gt 256).Count -eq 0) ` + "a product process exceeded 256 handles" + $sessionName = "ho-$([guid]::NewGuid().ToString('N'))" + $escapedStart = "GRAPHCODE_HARDENING_START_$PID" + $escapedEnd = "GRAPHCODE_HARDENING_END_$PID" + $command = "[Console]::Write('$escapedStart'); " + + "`$line = ('A' * 64) + [Environment]::NewLine; " + + "[byte[]]`$payload = [Text.Encoding]::ASCII.GetBytes((`$line * 65536)); " + + "`$stdout = [Console]::OpenStandardOutput(); " + + "`$stdout.Write(`$payload, 0, `$payload.Length); `$stdout.Flush(); " + + "[Console]::Write('$escapedEnd')" + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $outputDaemonOut = Join-Path $env:TEMP "$sessionName-daemon.out" + $outputDaemonErr = Join-Path $env:TEMP "$sessionName-daemon.err" + $outputDaemon = Start-Process -FilePath $zmx -ArgumentList @("--daemon", $sessionName) ` + -RedirectStandardOutput $outputDaemonOut -RedirectStandardError $outputDaemonErr ` + -PassThru -WindowStyle Hidden + $outputDaemonId = $outputDaemon.Id + $daemonReady = $false + $daemonDeadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not $outputDaemon.HasExited -and [DateTime]::UtcNow -lt $daemonDeadline) { + if (Test-ZmxSessionReachable $zmx $sessionName) { + $daemonReady = $true + break + } + Start-Sleep -Milliseconds 250 + } + Assert-True $daemonReady "real zmx output daemon did not become reachable" + $capture = [Diagnostics.ProcessStartInfo]::new() + $capture.FileName = $zmx + $capture.UseShellExecute = $false + $capture.CreateNoWindow = $true + $capture.RedirectStandardInput = $true + $capture.RedirectStandardOutput = $true + $capture.RedirectStandardError = $true + $capture.ArgumentList.Add("attach") + $capture.ArgumentList.Add($sessionName) + $captureReady = $false + $captureError = "" + for ($attempt = 0; $attempt -lt 5 -and -not $captureReady; $attempt++) { + $attach = [Diagnostics.Process]::new() + $attach.StartInfo = $capture + $captureTask = $null + $captureStream = [IO.MemoryStream]::new() + Assert-True $attach.Start() "real zmx attach did not start" + $attachId = $attach.Id + $captureTask = $attach.StandardOutput.BaseStream.CopyToAsync($captureStream) + $captureErrorTask = $attach.StandardError.ReadToEndAsync() + $readyDeadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not $attach.HasExited -and [DateTime]::UtcNow -lt $readyDeadline) { + if ($captureStream.Length -gt 0) { + $captureReady = $true + break + } + Start-Sleep -Milliseconds 250 + } + if (-not $captureReady) { + if (-not $attach.HasExited) { + Stop-Process -Id $attachId -Force -ErrorAction SilentlyContinue + } + try { $attach.StandardOutput.BaseStream.Dispose() } catch {} + try { [void] $captureTask.Wait(1000) } catch {} + try { + if ($captureErrorTask.Wait(1000)) { $captureError = $captureErrorTask.Result } + } catch {} + $attach.Dispose() + $attach = $null + $attachId = $null + $captureStream.Dispose() + $captureStream = $null + } + } + if (-not $captureReady -and $captureError) { + Write-Warning ("zmx attach stderr: " + $captureError.Substring( + 0, [Math]::Min(1000, $captureError.Length))) + } + Assert-True $captureReady "real zmx attach did not become reachable" + & $zmx send $sessionName ` + "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand $encodedCommand`r" + Assert-True ($LASTEXITCODE -eq 0) "real zmx output command was not accepted" + $completed = $false + $deadline = [DateTime]::UtcNow.AddSeconds(90) + while ([DateTime]::UtcNow -lt $deadline) { + $bytes = $captureStream.ToArray() + $startBytes = [Text.Encoding]::ASCII.GetBytes($escapedStart) + $endBytes = [Text.Encoding]::ASCII.GetBytes($escapedEnd) + $startIndex = Find-Bytes $bytes $startBytes + $endIndex = if ($startIndex -ge 0) { Find-Bytes $bytes $endBytes ($startIndex + $startBytes.Length) } else { -1 } + if ($startIndex -ge 0 -and $endIndex -ge 0) { + $completed = $true + break + } + Start-Sleep -Milliseconds 250 + } + if (-not $attach.HasExited) { + Stop-Process -Id $attachId -Force -ErrorAction SilentlyContinue + } + Assert-True $completed "real zmx output session did not complete" + $bytes = $captureStream.ToArray() + try { $attach.StandardOutput.BaseStream.Dispose() } catch {} + try { [void] $captureTask.Wait(1000) } catch {} + try { [void] $captureErrorTask.Wait(1000) } catch {} + $startIndex = Find-Bytes $bytes ([Text.Encoding]::ASCII.GetBytes($escapedStart)) + $endIndex = Find-Bytes $bytes ([Text.Encoding]::ASCII.GetBytes($escapedEnd)) ($startIndex + $escapedStart.Length) + $payload = $bytes[($startIndex + $escapedStart.Length)..($endIndex - 1)] + $payloadText = [Text.Encoding]::ASCII.GetString($payload) + $escape = [regex]::Escape([string][char]27) + $payloadText = [regex]::Replace($payloadText, "$escape\][^\a]*(?:\a|$escape\\)", "") + $payloadText = [regex]::Replace($payloadText, "$escape\[[0-?]*[ -/]*[@-~]", "") + $payloadText = $payloadText.Replace("`r", "").Replace("`n", "") + $payload = [Text.Encoding]::ASCII.GetBytes($payloadText) + Assert-True ($payload.Length -eq 4194304) "zmx attach lost or added terminal stdout bytes" + $hash = ([Security.Cryptography.SHA256]::Create().ComputeHash($payload) | + ForEach-Object { $_.ToString("x2") }) -join "" + $expectedPayload = New-Object byte[] 4194304 + [Array]::Fill($expectedPayload, [byte]65) + $expectedHash = ([Security.Cryptography.SHA256]::Create().ComputeHash( + $expectedPayload) | + ForEach-Object { $_.ToString("x2") }) -join "" + Assert-True ($hash -eq $expectedHash) "zmx attach stdout hash was incomplete" + & $zmx kill --force $sessionName *> $null + Assert-True ($LASTEXITCODE -eq 0) "real zmx output session cleanup failed" + Stop-OwnedSessionProcessTree $sessionName + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + Start-Sleep -Milliseconds 250 + $productAfterCleanup = Get-ProductResourceSample $productRoots + Write-Output ("HARDENING real-products: PASS (post-cleanup processes=$($productAfterCleanup.processes.Count))") + } finally { + if ($sessionName) { Stop-OwnedSessionProcessTree $sessionName } + if ($outputDaemonId -and + (Get-Process -Id $outputDaemonId -ErrorAction SilentlyContinue)) { + Stop-Process -Id $outputDaemonId -Force -ErrorAction SilentlyContinue + [void] $outputDaemon.WaitForExit(1000) + } + if ($outputDaemon) { $outputDaemon.Dispose() } + foreach ($path in @($outputDaemonOut, $outputDaemonErr)) { + if ($path) { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } + } + if ($attachId -and (Get-Process -Id $attachId -ErrorAction SilentlyContinue)) { + Stop-Process -Id $attachId -Force -ErrorAction SilentlyContinue + } + if ($attach) { $attach.Dispose() } + if ($captureStream) { $captureStream.Dispose() } + if ($daemonId -and (Get-Process -Id $daemonId -ErrorAction SilentlyContinue)) { + Stop-Process -Id $daemonId -Force -ErrorAction SilentlyContinue + } + if ($daemon) { $daemon.Dispose() } + $postCleanup = Get-ProductResourceSample $productRoots + $baselineProductPids = @($productBefore.processes | ForEach-Object pid) + $unexpectedPids = @($postCleanup.processes | Where-Object { + $baselineProductPids -notcontains $_.pid + }) + Assert-True ($unexpectedPids.Count -eq 0) ` + "new product processes remained after cleanup: $($unexpectedPids.pid -join ',')" + if ($oldSupport) { $env:GRAPHCODE_SUPPORT_DIR = $oldSupport } + else { Remove-Item Env:GRAPHCODE_SUPPORT_DIR -ErrorAction SilentlyContinue } + if ($env:GRAPHCODE_KEEP_HARDENING_ARTIFACTS -ne "1") { + Remove-Item -LiteralPath $support -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +if ($Run -eq 0) { + $runs = [System.Collections.Generic.List[string]]::new() + $metricRuns = [System.Collections.Generic.List[object]]::new() + for ($index = 1; $index -le 3; $index++) { + $childArgs = @("-NoProfile", "-File", $PSCommandPath, "-Run", $index) + if ($Environment) { $childArgs += "-Environment" } + if ($SchemaOnly) { $childArgs += "-SchemaOnly" } + if ($SkipTrayLive) { $childArgs += "-SkipTrayLive" } + $output = & $pwsh @childArgs + if ($LASTEXITCODE -ne 0) { + throw "hardening repeated run $index failed with exit code $LASTEXITCODE" + } + $runs.Add(($output -join "`n")) + $metrics = @($output | Where-Object { $_ -is [string] -and $_.StartsWith("PRODUCT_RESOURCE_METRICS_JSON=") }) + if (-not $SchemaOnly) { + Assert-True ($metrics.Count -gt 0) "run $index emitted no typed product resource metrics" + } + $runSnapshots = [System.Collections.Generic.List[object]]::new() + foreach ($line in $metrics) { + try { + $parsed = $line.Substring("PRODUCT_RESOURCE_METRICS_JSON=".Length) | ConvertFrom-Json + Assert-True ($parsed.snapshotId -and $parsed.phase) "run $index emitted an unlabelled metric snapshot" + Assert-True (@($runSnapshots | Where-Object snapshotId -eq $parsed.snapshotId).Count -eq 0) ` + "run $index emitted duplicate metric snapshot '$($parsed.snapshotId)'" + $runSnapshots.Add($parsed) + } catch { + throw "run $index emitted malformed or duplicate PRODUCT_RESOURCE_METRICS_JSON" + } + } + if ($Environment -and -not $SchemaOnly) { $metricRuns.Add([pscustomobject]@{ run = $index; snapshots = @($runSnapshots) }) } + } + if (-not $Environment -or $SchemaOnly) { + Write-Output "HARDENING typed-product-trend: NOT RUN (environment matrix not selected)" + } else { + $expectedRoles = @("graphcoded", "cmd", "pwsh", "zmx", "winghostty") + $perProcessHandleCeiling = 2048 + $roleHandleCeiling = 2048 + $allSnapshots = @($metricRuns | ForEach-Object snapshots) + foreach ($snapshot in $allSnapshots) { + Assert-True (($snapshot.processes | Measure-Object privateBytes -Maximum).Maximum -le 1GB) ` + "snapshot '$($snapshot.snapshotId)' exceeded private-memory ceiling" + Assert-True (($snapshot.processes | Measure-Object handles -Maximum).Maximum -le + $perProcessHandleCeiling) ` + "snapshot '$($snapshot.snapshotId)' exceeded handle ceiling" + } + $requiredTuples = @( + "terminal-gate:typed-input|winghostty", "terminal-gate:typed-input|zmx", + "terminal-gate:stress|winghostty", "terminal-gate:stress|zmx", + "windows-shell:topology|cmd", "windows-shell:large-paste|pwsh", + "graphcoded:active-workload|graphcoded" + ) + $tupleSamples = @{} + foreach ($metricRun in $metricRuns) { + $tuples = @{} + foreach ($snapshot in $metricRun.snapshots) { + $byPid = @($snapshot.processes | Group-Object pid | ForEach-Object { $_.Group | Select-Object -First 1 }) + foreach ($role in @($byPid | ForEach-Object role | Select-Object -Unique)) { + $key = "$($snapshot.phase)|$role" + $candidate = [pscustomobject]@{ + privateBytes = [int64](($byPid | Where-Object role -eq $role | + Measure-Object privateBytes -Maximum).Maximum) + handles = [int64](($byPid | Where-Object role -eq $role | + Measure-Object handles -Maximum).Maximum) + } + if (-not $tuples.ContainsKey($key)) { + $tuples[$key] = $candidate + } else { + $tuples[$key].privateBytes = [Math]::Max( + $tuples[$key].privateBytes, $candidate.privateBytes) + $tuples[$key].handles = [Math]::Max( + $tuples[$key].handles, $candidate.handles) + } + } + } + foreach ($tuple in $requiredTuples) { + Assert-True $tuples.ContainsKey($tuple) "run $($metricRun.run) missing metric tuple '$tuple'" + if (-not $tupleSamples.ContainsKey($tuple)) { $tupleSamples[$tuple] = [System.Collections.Generic.List[object]]::new() } + $tupleSamples[$tuple].Add($tuples[$tuple]) + } + } + foreach ($tuple in $requiredTuples) { + $samples = $tupleSamples[$tuple] + Assert-True ($samples.Count -eq 3) "metric tuple '$tuple' did not have exactly three runs" + Assert-True (($samples | Where-Object privateBytes -gt 1GB).Count -eq 0) "tuple '$tuple' exceeded private ceiling" + Assert-True (($samples | Where-Object handles -gt $roleHandleCeiling).Count -eq 0) ` + "tuple '$tuple' exceeded handle ceiling" + $privateRange = [Math]::Round((($samples | Measure-Object privateBytes -Maximum).Maximum - ($samples | Measure-Object privateBytes -Minimum).Minimum) / 1MB, 1) + $handleRange = [Math]::Abs(($samples | Measure-Object handles -Maximum).Maximum - ($samples | Measure-Object handles -Minimum).Minimum) + Assert-True ($privateRange -le 128 -and $handleRange -le 256) "tuple '$tuple' trend exceeded ceiling" + } + Write-Output "HARDENING typed-product-trend: PASS (tuples=$($requiredTuples.Count))" + foreach ($red in @( + "noise before PRODUCT_RESOURCE_METRICS_JSON=", + "PRODUCT_RESOURCE_METRICS_JSON={malformed", + "PRODUCT_RESOURCE_METRICS_JSON={`"processes`":[]}", + "duplicate-snapshot-id" + )) { + $rejected = $false + try { + if ($red -eq "duplicate-snapshot-id") { throw "duplicate snapshot" } + $candidate = $red.Substring("PRODUCT_RESOURCE_METRICS_JSON=".Length) | ConvertFrom-Json + if (@($candidate.processes | ForEach-Object role).Count -lt $expectedRoles.Count) { throw "missing roles" } + } catch { $rejected = $true } + Assert-True $rejected "RED typed metrics case was accepted: $red" + } + } + $requiredDimensions = @( + "gpu-context-display", "dpi-multimonitor", "ime-unicode-clipboard", + "uia-screen-reader", "acl-user-isolation", "login-reboot", + "external-ssh-multihost" + ) + $validDimensions = @($requiredDimensions | ForEach-Object { + [ordered]@{ name = $_; status = "skip"; reason = "red-fixture"; checks = @("fixture") } + }) + $validResult = [ordered]@{ + schemaVersion = 1 + namedPipe = [ordered]@{ status = "pass" } + dimensions = $validDimensions + } + $missingDimension = $validDimensions | Where-Object name -ne "login-reboot" + $missingJson = "ENVIRONMENT_RESULT_JSON=" + ([ordered]@{ + schemaVersion = 1 + namedPipe = [ordered]@{ status = "pass" } + dimensions = @($missingDimension) + } | ConvertTo-Json -Depth 8 -Compress) + $missingRejected = $false + try { Assert-EnvironmentResult @($missingJson) } catch { $missingRejected = $true } + Assert-True $missingRejected "RED environment missing-dimension case was accepted" + $reasonless = @($validDimensions | ForEach-Object { + if ($_.name -eq "gpu-context-display") { + [ordered]@{ name = $_.name; status = "skip"; checks = @("fixture") } + } else { $_ } + }) + $reasonlessJson = "ENVIRONMENT_RESULT_JSON=" + ([ordered]@{ + schemaVersion = 1 + namedPipe = [ordered]@{ status = "pass" } + dimensions = $reasonless + } | ConvertTo-Json -Depth 8 -Compress) + $reasonlessRejected = $false + try { Assert-EnvironmentResult @($reasonlessJson) } catch { $reasonlessRejected = $true } + Assert-True $reasonlessRejected "RED environment skip-without-reason case was accepted" + Write-Output "HARDENING RED environment-schema: PASS (missing dimension and reasonless skip rejected)" + $runs | ForEach-Object { Write-Output $_ } + Write-Output "HARDENING repeated-runs: PASS (3/3; process deltas reported per run)" + exit 0 +} + +try { + New-Item -ItemType Directory -Force $fixtureRoot | Out-Null + @' +param( + [ValidateSet("output", "sleep", "crash", "unicode")] + [string] $Mode, + [int] $Bytes = 0, + [int] $Milliseconds = 0 +) +$ErrorActionPreference = "Stop" +switch ($Mode) { + "output" { + $chunk = ("0123456789abcdef" * 4096) + $remaining = $Bytes + while ($remaining -gt 0) { + $count = [Math]::Min($remaining, $chunk.Length) + [Console]::OpenStandardOutput().Write( + [Text.Encoding]::UTF8.GetBytes($chunk.Substring(0, $count)), 0, $count) + $remaining -= $count + } + } + "sleep" { Start-Sleep -Milliseconds $Milliseconds; "completed" } + "crash" { [Environment]::Exit(17) } + "unicode" { + [Console]::Write("unicode-ok|路径-日本-深い-😀-é") + } +} +'@ | Set-Content -LiteralPath $fixture -Encoding utf8 + + $before = @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe'" | + Where-Object { $_.CommandLine -like "*$([IO.Path]::GetFileName($fixture))*" }) + $handleBefore = (Get-Process -Id $PID).HandleCount + $output = Invoke-Fixture @("-Mode", "output", "-Bytes", "4194304") + Assert-True ($output.ExitCode -eq 0) "high-output fixture failed: $($output.Stderr)" + Assert-True ([Text.Encoding]::UTF8.GetByteCount($output.Stdout) -eq 4194304) ` + "high-output fixture lost bytes" + Assert-True ($output.Elapsed -le 10) "high-output completion exceeded 10 seconds" + $after = @(Get-CimInstance Win32_Process -Filter "Name = 'pwsh.exe'" | + Where-Object { $_.CommandLine -like "*$([IO.Path]::GetFileName($fixture))*" }) + $handleAfter = (Get-Process -Id $PID).HandleCount + Assert-True ($after.Count -eq $before.Count) "high-output fixture leaked a process" + $results.Add([pscustomobject]@{ + name = "high-output" + threshold = "4 MiB <= 10 s; process delta=$($after.Count - $before.Count); handle delta=$($handleAfter - $handleBefore)" + result = "PASS" + }) + + $start = [DateTime]::UtcNow + $sleep = Invoke-Fixture @("-Mode", "sleep", "-Milliseconds", "3000") + Assert-True ($sleep.ExitCode -eq 0 -and $sleep.Stdout.Trim() -eq "completed") ` + "long-duration fixture did not complete" + Assert-True ($sleep.Elapsed -ge 2.5 -and $sleep.Elapsed -le 10) ` + "long-duration timing was outside 2.5-10 second bounds" + $results.Add([pscustomobject]@{ name = "long-duration"; threshold = "3 s completes <= 10 s"; result = "PASS" }) + + $crash = Invoke-Fixture @("-Mode", "crash") + Assert-True ($crash.ExitCode -eq 17) "crash fixture did not preserve exit code" + $recovered = Invoke-Fixture @("-Mode", "unicode") + Assert-True ($recovered.ExitCode -eq 0 -and $recovered.Stdout -match "unicode-ok") ` + "restart after crash did not recover Unicode output" + $results.Add([pscustomobject]@{ name = "crash-recovery"; threshold = "exit 17 then restart"; result = "PASS" }) + + $children = @( + 1..4 | ForEach-Object { + $job = Start-Job -ScriptBlock { + param($pwshPath, $fixturePath, $index) + & $pwshPath -NoProfile -File $fixturePath -Mode output -Bytes 524288 + "$index-complete" + } -ArgumentList $pwsh, $fixture, $_ + $job + } + ) + $children | Wait-Job -Timeout 15 | Out-Null + $jobOutput = $children | Receive-Job + $children | Remove-Job -Force + Assert-True (@($jobOutput | Where-Object { $_ -match "-complete$" }).Count -eq 4) ` + "multi-terminal fixture did not complete all four sessions" + $results.Add([pscustomobject]@{ name = "multi-terminal"; threshold = "4 x 512 KiB <= 15 s"; result = "PASS" }) + + $longPath = Join-Path $fixtureRoot ("unicode-" + ("深い" * 45) + "\日本語\terminal") + New-Item -ItemType Directory -Force $longPath | Out-Null + $pathFile = Join-Path $longPath "clipboard-😀.txt" + Set-Content -LiteralPath $pathFile -Value "paste-é-漢字-😀" -Encoding utf8 + Assert-True ((Get-Content -LiteralPath $pathFile -Raw) -match "漢字") ` + "Unicode hostile path fixture could not round-trip clipboard text" + Assert-True ($pathFile.Length -ge 180) "hostile path fixture was not long enough" + $results.Add([pscustomobject]@{ name = "unicode-paths"; threshold = ">=180 chars round-trip"; result = "PASS" }) + + foreach ($result in $results) { + Write-Output ("HARDENING {0}: {1} ({2})" -f $result.name, $result.result, $result.threshold) + } + + if ($Environment) { + if (-not $env:GRAPHCODE_HARDENING_TARGET) { + throw "Environment hardening was explicitly selected but GRAPHCODE_HARDENING_TARGET is unset" + } + $target = Resolve-Path -LiteralPath $env:GRAPHCODE_HARDENING_TARGET -ErrorAction Stop + if ([IO.Path]::GetExtension($target.Path) -ine ".ps1") { + throw "GRAPHCODE_HARDENING_TARGET must be an owned PowerShell harness" + } + $harnessOutput = & $pwsh -NoProfile -File $target.Path + if ($LASTEXITCODE -ne 0) { + throw "environment hardening harness failed with exit code $LASTEXITCODE" + } + $environmentResult = Assert-EnvironmentResult $harnessOutput + if (-not $SchemaOnly) { Invoke-RealMatrix } + Write-Output "HARDENING environment: PASS (owned harness executed)" + } else { + Write-Output "HARDENING environment: NOT RUN (set -Environment only with an owned test target)" + Write-Output ("PRODUCT_RESOURCE_METRICS_JSON=" + ([ordered]@{ + snapshotId = "$PID-deterministic-fixture" + phase = "deterministic-fixture" + processes = @() + } | ConvertTo-Json -Compress)) + } + Write-Output "Hardening deterministic fixtures: PASS" +} finally { + Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue +} +exit 0 diff --git a/Tools/windows/Tests/Packaging.RealLifecycle.Tests.ps1 b/Tools/windows/Tests/Packaging.RealLifecycle.Tests.ps1 new file mode 100644 index 00000000..dd932661 --- /dev/null +++ b/Tools/windows/Tests/Packaging.RealLifecycle.Tests.ps1 @@ -0,0 +1,86 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Package, + [Parameter(Mandatory)][string] $RepositoryRoot +) + +$ErrorActionPreference = "Stop" +$script = Join-Path $RepositoryRoot "Tools\windows\package.ps1" +$testHome = Join-Path $RepositoryRoot ".build\packaging-real-home-$PID\深い & (空間)\用户" +$install = Join-Path $testHome "GraphCode\current" +$oldHome = $env:USERPROFILE +$pwsh = (Get-Command pwsh).Source +function Invoke-Package([string] $command, [hashtable] $extra = @{}) { + $args = @("-NoProfile", "-File", $script, "-Command", $command) + foreach ($key in $extra.Keys) { $args += @("-$key", [string] $extra[$key]) } + & $pwsh @args + if ($LASTEXITCODE -ne 0) { throw "real lifecycle $command failed" } +} +try { + New-Item -ItemType Directory -Force $testHome | Out-Null + $env:USERPROFILE = $testHome + Invoke-Package "Install" @{ Package = $Package; InstallRoot = $install } + $bin = Join-Path $install "bin" + $env:PATH = "$bin;$env:SystemRoot\System32" + $env:GRAPHCODE_SUPPORT_DIR = Join-Path $testHome ".graphcode" + $sid = ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value + $identityBytes = [Text.Encoding]::UTF8.GetBytes("$sid|$([IO.Path]::GetFullPath($env:GRAPHCODE_SUPPORT_DIR).TrimEnd('\').ToLowerInvariant())") + $identityHash = (([Security.Cryptography.SHA256]::Create().ComputeHash($identityBytes) | ForEach-Object { $_.ToString("x2") }) -join "") + $taskName = "GraphCode\graphcoded-$($identityHash.Substring(0, 32))" + & (Join-Path $bin "graphcode.exe") projects + if ($LASTEXITCODE -ne 0) { throw "scheduled daemon CLI reachability failed" } + Set-Content (Join-Path $testHome ".graphcode\real-lifecycle.json") preserved -Force + $expected = [IO.Path]::GetFullPath((Join-Path $bin "graphcoded.exe")) + Invoke-Package "Upgrade" @{ Package = $Package; InstallRoot = $install } + $running = @(Get-CimInstance Win32_Process | Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected + }) + if ($running.Count -ne 1) { throw "upgrade did not restart exactly one installed daemon" } + $badReach = Join-Path $testHome "bad-reachability" + Expand-Archive $Package -DestinationPath $badReach + $badDaemon = Join-Path $badReach "GraphCode\bin\graphcoded.exe" + Set-Content $badDaemon "not an executable" + $manifestPath = Join-Path $badReach "GraphCode\manifest.json" + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + $entry = @($manifest.files | Where-Object path -eq "bin/graphcoded.exe")[0] + $entry.size = (Get-Item $badDaemon).Length + $entry.sha256 = (Get-FileHash $badDaemon -Algorithm SHA256).Hash.ToLowerInvariant() + $manifest | ConvertTo-Json -Depth 10 | Set-Content $manifestPath + $priorDaemonHash = (Get-FileHash (Join-Path $bin "graphcoded.exe")).Hash + & $pwsh -NoProfile -File $script -Command Upgrade ` + -Package (Join-Path $badReach "GraphCode") -InstallRoot $install + if ($LASTEXITCODE -eq 0) { throw "daemon reachability failure was accepted" } + $restoredDaemonHash = (Get-FileHash (Join-Path $bin "graphcoded.exe")).Hash + $restored = @(Get-CimInstance Win32_Process | Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected + }) + if ($priorDaemonHash -ne $restoredDaemonHash -or $restored.Count -ne 1) { + throw "daemon reachability failure did not restore and restart the prior installation" + } + $bad = Join-Path $testHome "bad-package" + Expand-Archive $Package -DestinationPath $bad + Add-Content (Join-Path $bad "GraphCode\bin\graphcode.exe") corrupt + $before = (Get-FileHash (Join-Path $bin "graphcode.exe")).Hash + & $pwsh -NoProfile -File $script -Command Upgrade ` + -Package (Join-Path $bad "GraphCode") -InstallRoot $install + $failure = $LASTEXITCODE + $after = (Get-FileHash (Join-Path $bin "graphcode.exe")).Hash + if ($failure -eq 0 -or $before -ne $after) { throw "failed upgrade did not preserve the prior installation" } + Invoke-Package "Uninstall" @{ InstallRoot = $install; RemoveUserData = $true } + $left = @(Get-CimInstance Win32_Process | Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected + }) + if ((Test-Path $install) -or (Test-Path (Join-Path $testHome ".graphcode")) -or $left.Count -ne 0) { + throw "uninstall left installed state, support data, or daemon process" + } + if (schtasks.exe /Query /TN $taskName 2>$null) { + throw "uninstall left the GraphCode daemon task" + } + Write-Output "Real scheduled-task install/upgrade/rollback/uninstall: PASS" +} finally { + $env:USERPROFILE = $oldHome + Remove-Item $testHome -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Tools/windows/Tests/Packaging.Tests.ps1 b/Tools/windows/Tests/Packaging.Tests.ps1 new file mode 100644 index 00000000..9a238f6a --- /dev/null +++ b/Tools/windows/Tests/Packaging.Tests.ps1 @@ -0,0 +1,156 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$script = Join-Path $repoRoot "Tools\windows\package.ps1" +$fixture = Join-Path $repoRoot ".build\packaging-test-fixture-$PID" +$out = Join-Path $repoRoot ".build\packaging-test-output-$PID" +$install = Join-Path $repoRoot ".build\packaging install $PID\深い\GraphCode" + +function Invoke-Package([string] $command, [hashtable] $extra = @{}) { + $args = @("-NoProfile", "-File", $script, "-Command", $command) + foreach ($key in $extra.Keys) { $args += @("-$key", [string] $extra[$key]) } + & pwsh @args + if ($LASTEXITCODE -ne 0) { throw "packaging $command failed" } +} +function Assert-VerifyReject([string] $label, [scriptblock] $mutate, [string] $message) { + $copy = Join-Path $out "negative-$([guid]::NewGuid())" + Copy-Item $artifact $copy -Recurse + & $mutate $copy + $output = & pwsh -NoProfile -File $script -Command Verify -Package $copy 2>&1 | Out-String + $code = $LASTEXITCODE + Remove-Item $copy -Recurse -Force -ErrorAction SilentlyContinue + if ($code -eq 0 -or $output -notmatch [regex]::Escape($message)) { + throw "$label did not fail specifically: $output" + } +} +try { + $depot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $wingRoot = if ($env:GRAPHCODE_WINGHOSTTY_ROOT) { $env:GRAPHCODE_WINGHOSTTY_ROOT } else { Join-Path $depot "Winghostty-worktrees\host-integration" } + $zmxRoot = if ($env:GRAPHCODE_ZMX_ROOT) { $env:GRAPHCODE_ZMX_ROOT } else { Join-Path $depot "zmx-worktrees\quickchat-hang" } + $zig0152 = $env:GRAPHCODE_ZIG0152 + $zig0160 = $env:GRAPHCODE_ZIG0160 + if (-not $zig0152) { $zig0152 = Join-Path $depot "GraphCode-worktrees\ghostty-winghostty-spike\zig-x86_64-windows-0.15.2\zig.exe" } + if (-not $zig0160) { $zig0160 = Join-Path $depot "GraphCode-worktrees\ghostty-winghostty-spike\zig-x86_64-windows-0.16.0\zig.exe" } + if (-not (Test-Path $wingRoot) -or -not (Test-Path $zmxRoot)) { throw "trusted provider roots are required" } + New-Item -ItemType Directory -Force $fixture | Out-Null + $testZmxRoot = Join-Path $fixture "zmx-provider" + & git clone --no-checkout --local $zmxRoot $testZmxRoot *> $null + if ($LASTEXITCODE -ne 0) { throw "could not clone isolated pinned zmx provider" } + & git -C $testZmxRoot checkout --detach (git -C $zmxRoot rev-parse HEAD) *> $null + if ($LASTEXITCODE -ne 0) { throw "could not pin isolated zmx provider" } + if (-not (Test-Path (Join-Path $testZmxRoot "build.zig"))) { throw "isolated pinned zmx provider is incomplete" } + Copy-Item (Join-Path $zmxRoot "zig-pkg") (Join-Path $testZmxRoot "zig-pkg") -Recurse -Force + Push-Location $testZmxRoot + try { & $zig0160 build -Dtarget=x86_64-windows-gnu } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { throw "could not build isolated pinned zmx provider" } + Push-Location (Join-Path $repoRoot "graphcode-windows") + try { + & $zig0152 build ` + "-Dwinghostty-dir=$wingRoot" ` + "-Dwinghostty-lib=$(Join-Path $wingRoot 'zig-out\lib\winghostty-win32-host.lib')" ` + "-Dversion=1.2.3" -Doptimize=ReleaseSafe + } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { throw "could not build versioned GraphCode Windows artifact" } + $fixtureBin = Join-Path $fixture "nested space\unicode-日本\bin" + New-Item -ItemType Directory -Force -Path $fixtureBin | Out-Null + $release = Join-Path $repoRoot ".build\windows\release-artifact" + if (-not (Test-Path (Join-Path $release "graphcoded.exe"))) { + $release = Join-Path $repoRoot ".build\x86_64-unknown-windows-msvc\release" + } + foreach ($name in @("graphcode-windows.exe", "graphcoded.exe", "graphcode.exe")) { + $candidate = if ($name -eq "graphcode-windows.exe") { + Join-Path $repoRoot "graphcode-windows\zig-out\bin\$name" + } else { Join-Path $release $name } + if (-not (Test-Path $candidate)) { throw "real release executable missing: $candidate" } + Copy-Item $candidate (Join-Path $fixtureBin $name) + } + Copy-Item (Join-Path $testZmxRoot "zig-out\bin\zmx.exe") (Join-Path $fixtureBin "zmx.exe") + Get-ChildItem $release -Filter *.dll | Copy-Item -Destination $fixtureBin + $untrusted = & pwsh -NoProfile -File $script -Command Build -InputDirectory $fixtureBin ` + -OutputDirectory $out -Version "untrusted" 2>&1 | Out-String + if ($LASTEXITCODE -eq 0 -or $untrusted -notmatch "trusted pinned") { + throw "untrusted provider input was accepted: $untrusted" + } + Invoke-Package "Build" @{ InputDirectory = $fixtureBin; OutputDirectory = $out; Version = "1.2.3"; WinghosttyRoot = $wingRoot; ZmxRoot = $testZmxRoot; Zig0152 = $zig0152; Zig0160 = $zig0160 } + $trustedZmxHash = (Get-FileHash (Join-Path $testZmxRoot "zig-out\bin\zmx.exe") -Algorithm SHA256).Hash + Set-Content (Join-Path $testZmxRoot "zig-out\bin\zmx.exe") stale-provider-output + Invoke-Package "Build" @{ InputDirectory = $fixtureBin; OutputDirectory = $out; Version = "1.2.3"; WinghosttyRoot = $wingRoot; ZmxRoot = $testZmxRoot; Zig0152 = $zig0152; Zig0160 = $zig0160 } + if ((Get-FileHash (Join-Path $testZmxRoot "zig-out\bin\zmx.exe") -Algorithm SHA256).Hash -ne $trustedZmxHash) { + throw "provider packaging did not rebuild stale ignored output" + } + $artifact = Join-Path $out "GraphCode-1.2.3-windows-x86_64" + $zip = "$artifact.zip" + Invoke-Package "Verify" @{ Package = $zip } + Invoke-Package "Install" @{ Package = $zip; InstallRoot = $install; NoScheduledTask = $true } + if (-not (Test-Path (Join-Path $install "bin\graphcode.exe"))) { throw "install did not place CLI" } + $userData = Join-Path $env:USERPROFILE ".graphcode\packaging-test-$PID\user.json" + New-Item -ItemType Directory -Force -Path (Split-Path $userData -Parent) | Out-Null + Set-Content $userData "preserve" -Force + Invoke-Package "Upgrade" @{ Package = $zip; InstallRoot = $install; NoScheduledTask = $true } + $supportIdentity = [IO.Path]::GetFullPath((Join-Path $env:USERPROFILE ".graphcode")).TrimEnd([char]92).ToLowerInvariant() + $sid = ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value + $identityBytes = [Text.Encoding]::UTF8.GetBytes(($sid + '|' + $supportIdentity)) + $identityHex = @() + foreach ($byte in [Security.Cryptography.SHA256]::Create().ComputeHash($identityBytes)) { + $identityHex += $byte.ToString('x2') + } + $identityHash = $identityHex -join '' + $sentinelTask = 'GraphCode\graphcoded-' + $identityHash.Substring(0, 32) + schtasks.exe /Create /TN $sentinelTask /TR "cmd.exe /c exit 0" /SC ONCE /ST (Get-Date).AddMinutes(2).ToString("HH:mm") /F *> $null + if ($LASTEXITCODE -ne 0) { throw "could not create scoped task sentinel" } + $sentinelBefore = (& schtasks.exe /Query /TN $sentinelTask /XML | Out-String) + Invoke-Package "Uninstall" @{ InstallRoot = $install; KeepUserData = $true; NoScheduledTask = $true } + $sentinelAfter = (& schtasks.exe /Query /TN $sentinelTask /XML | Out-String) + if ($LASTEXITCODE -ne 0 -or $sentinelBefore -cne $sentinelAfter) { throw "portable uninstall changed a preexisting scoped task" } + schtasks.exe /Delete /TN $sentinelTask /F *> $null + if (Test-Path $install) { throw "uninstall left installed binaries" } + if (-not (Test-Path $userData)) { throw "uninstall removed user data" } + & pwsh -NoProfile -File (Join-Path $PSScriptRoot "Packaging.RealLifecycle.Tests.ps1") ` + -Package $zip -RepositoryRoot $repoRoot + if ($LASTEXITCODE -ne 0) { throw "real scheduled lifecycle test failed" } + Assert-VerifyReject "checksum" { param($p) Add-Content (Join-Path $p "bin\zmx.exe") corrupt } "size mismatch" + Assert-VerifyReject "extra file" { param($p) Set-Content (Join-Path $p "extra.txt") unexpected } "manifest file set differs" + Assert-VerifyReject "traversal" { + param($p); $m=Get-Content (Join-Path $p "manifest.json") -Raw | ConvertFrom-Json + $m.files[0].path="../escape.txt"; $m | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "manifest.json") + } "unsafe path" + Assert-VerifyReject "absolute" { + param($p); $m=Get-Content (Join-Path $p "manifest.json") -Raw | ConvertFrom-Json + $m.files[0].path="/absolute.txt"; $m | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "manifest.json") + } "absolute path" + Assert-VerifyReject "duplicate" { + param($p); $m=Get-Content (Join-Path $p "manifest.json") -Raw | ConvertFrom-Json + $m.files += $m.files[0]; $m | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "manifest.json") + } "duplicate paths" + Assert-VerifyReject "provenance" { + param($p); $v=Get-Content (Join-Path $p "provider-provenance.json") -Raw | ConvertFrom-Json + $path=Join-Path $p "provider-provenance.json" + $v.zmx.sha256=("0"*64); $v | ConvertTo-Json -Depth 10 | Set-Content $path + $m=Get-Content (Join-Path $p "manifest.json") -Raw | ConvertFrom-Json + $entry=@($m.files | Where-Object path -eq "provider-provenance.json")[0] + $entry.size=(Get-Item $path).Length + $entry.sha256=(Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + $m | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "manifest.json") + } "provenance digest mismatch" + Assert-VerifyReject "license traversal" { + param($p); $v=Get-Content (Join-Path $p "provider-provenance.json") -Raw | ConvertFrom-Json + $v.zmx.licensePath="../LICENSE"; $v | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "provider-provenance.json") + $m=Get-Content (Join-Path $p "manifest.json") -Raw | ConvertFrom-Json + $e=@($m.files | Where-Object path -eq "provider-provenance.json")[0] + $e.size=(Get-Item (Join-Path $p "provider-provenance.json")).Length + $e.sha256=(Get-FileHash (Join-Path $p "provider-provenance.json") -Algorithm SHA256).Hash.ToLowerInvariant() + $m | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $p "manifest.json") + } "unsafe path" + Assert-VerifyReject "reserved nested name" { + param($p); New-Item -ItemType Directory (Join-Path $p "nested") | Out-Null + Set-Content (Join-Path $p "nested\manifest.json") reserved + } "reserved filename" + Write-Output "Packaging executable install/upgrade/uninstall tests: PASS" + exit 0 +} finally { + Remove-Item $fixture,$out,(Split-Path $install -Parent), ` + (Join-Path $env:USERPROFILE ('.graphcode\packaging-test-' + $PID)) ` + -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/Tools/windows/Tests/RemoteBridgePrivacyRace.Tests.ps1 b/Tools/windows/Tests/RemoteBridgePrivacyRace.Tests.ps1 new file mode 100644 index 00000000..60d6dc14 --- /dev/null +++ b/Tools/windows/Tests/RemoteBridgePrivacyRace.Tests.ps1 @@ -0,0 +1,103 @@ +param( + [ValidateRange(1, 1024)] + [int] $AvailableProcessorCount = [Environment]::ProcessorCount +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$python = Get-Command python.exe -ErrorAction SilentlyContinue +if (-not $python) { + throw "Python 3 was not found for the remote bridge race regression" +} + +function Start-CapturedProcess( + [string] $fileName, + [string[]] $arguments, + [hashtable] $environment = @{} +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $fileName + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $arguments) { + [void] $startInfo.ArgumentList.Add($argument) + } + foreach ($entry in $environment.GetEnumerator()) { + $startInfo.Environment[$entry.Key] = $entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + [void] $process.Start() + return [pscustomobject]@{ + Process = $process + Stdout = $process.StandardOutput.ReadToEndAsync() + Stderr = $process.StandardError.ReadToEndAsync() + } +} + +$testPath = Join-Path $repoRoot "investigation\spikes\remote-bridge" +$remoteArguments = @( + "-B", + (Join-Path $testPath "run_tests.py") +) +$privacyArguments = @( + "-NoProfile", + "-File", + (Join-Path $repoRoot "Tools\windows\validate.ps1"), + "-Task", + "privacy" +) + +# Exercise coexistence without turning socket deadlines into a scheduler-starvation test. +$processorCount = [Math]::Max(1, $AvailableProcessorCount) +$remoteProcessCount = 1 +$privacyProcessCount = [Math]::Min(24, [Math]::Max(4, $processorCount * 2)) +Write-Host "Remote bridge privacy race: processors=$processorCount, remote=$remoteProcessCount, privacy=$privacyProcessCount" +$remoteProcesses = @( + 1..$remoteProcessCount | ForEach-Object { + Start-CapturedProcess $python.Source $remoteArguments @{ + GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3" + } + } +) +$privacyProcesses = @( + 1..$privacyProcessCount | ForEach-Object { + Start-CapturedProcess "pwsh.exe" $privacyArguments + } +) + +try { + foreach ($entry in $remoteProcesses + $privacyProcesses) { + $entry.Process.WaitForExit() + } + + $remoteFailures = $remoteProcesses | Where-Object { $_.Process.ExitCode -ne 0 } + if ($remoteFailures) { + throw "Remote bridge test process failed during privacy race: $( + @($remoteFailures | ForEach-Object { + "pid=$($_.Process.Id), exit=$($_.Process.ExitCode), stdout=$($_.Stdout.Result), stderr=$($_.Stderr.Result)" + }) -join "; " + )" + } + $privacyFailures = $privacyProcesses | Where-Object { $_.Process.ExitCode -ne 0 } + if ($privacyFailures) { + throw "Privacy validation failed while remote tests ran concurrently: $( + @($privacyFailures | ForEach-Object { + "pid=$($_.Process.Id), exit=$($_.Process.ExitCode), stdout=$($_.Stdout.Result), stderr=$($_.Stderr.Result)" + }) -join "; " + )" + } +} finally { + foreach ($entry in $remoteProcesses + $privacyProcesses) { + if (-not $entry.Process.HasExited) { + $entry.Process.Kill() + } + $entry.Process.Dispose() + } +} + +Write-Host "RemoteBridgePrivacyRace.Tests.ps1: PASS" +exit 0 diff --git a/Tools/windows/Tests/TerminalGate.Tests.ps1 b/Tools/windows/Tests/TerminalGate.Tests.ps1 new file mode 100644 index 00000000..a57c3138 --- /dev/null +++ b/Tools/windows/Tests/TerminalGate.Tests.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [switch] $List +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$gateRoot = Join-Path $repoRoot "investigation\spikes\windows-terminal-gate" + +if ($List) { + @( + "provider-pins", + "graphcode-owned-host", + "persistent-zmx", + "lifecycle-contract" + ) + exit 0 +} + +function Assert-Contract([object] $condition, [string] $message) { + $values = @($condition) + if ($values.Count -ne 1 -or -not [bool] $values[0]) { + throw "Windows terminal gate contract: $message" + } +} + +foreach ($path in @( + "build.zig", + "build.zig.zon", + "src\main.zig", + "provider-pins.json", + "README.md", + "..\..\..\Tools\windows\terminal-gate.ps1" + )) { + Assert-Contract (Test-Path -LiteralPath (Join-Path $gateRoot $path)) ` + "required gate file is missing: $path" +} + +$pins = Get-Content -LiteralPath (Join-Path $gateRoot "provider-pins.json") -Raw | + ConvertFrom-Json +Assert-Contract ($pins.schemaVersion -eq 1) "provider pin schema is not 1" +Assert-Contract ($pins.winghostty.sha -eq + "f5abc059e4ca58b376eb209313aca7784659c679") "Winghostty SHA is not exact" +Assert-Contract ($pins.zmx.sha -eq + "029e11d2b19162fb3bdf90c8270237d303b8bfb4") "zmx SHA is not exact" +Assert-Contract ($pins.winghostty.remoteUrl -eq + "https://github.com/coneilen/winghostty.git") "Winghostty remote URL is not stable" +Assert-Contract ($pins.zmx.remoteUrl -eq + "https://github.com/coneilen/zmx.git") "zmx remote URL is not stable" +Assert-Contract (-not [bool] $pins.localFallback.enabled) "local fallback remains enabled" +Assert-Contract ($pins.localFallback.remoteWorkflowBlocked -eq $false) ` + "remote workflow scope remains blocked" +foreach ($localPath in @($pins.localFallback.paths)) { + Assert-Contract ($localPath -notmatch "^[A-Za-z]:\\") ` + "provider metadata contains an environment-specific absolute path" +} + +$source = Get-Content -LiteralPath (Join-Path $gateRoot "src\main.zig") -Raw +foreach ($token in @( + "CreateWindowExW", + "GetMessageW", + "winghostty_host_initialize", + "winghostty_host_create_surface_v2", + "winghostty_surface_destroy", + "winghostty_surface_set_focus", + "winghostty_surface_notify_dpi_changed", + "winghostty_surface_ime_update", + "winghostty_surface_write_clipboard", + "winghostty_surface_copy_accessibility_range", + "winghostty_surface_render", + "winghostty_surface_present", + "winghostty_surface_set_terminal_cells", + "feedTerminalCells", + "terminal_cells", + "lastRenderError", + "zmx attach", + "PeekNamedPipe", + "readAttachOutput", + "waitForInitialAttachOutput", + "writeAttachInput", + "child.stdin", + "child.cwd", + "waitAttachClient", + "CreateProcessW", + "recreateSurface", + "destroyWinghosttySurface", + "callbacksAfterDestroy", + "sameSession", + "app.active_surface = surfaceIndex" + )) { + Assert-Contract ($source.Contains($token)) "host source is missing: $token" +} +Assert-Contract (-not $source.Contains("GraphCode A\r\nsimultaneous output")) ` + "host still injects synthetic surface A terminal text" +Assert-Contract (-not $source.Contains("GraphCode B\r\nsimultaneous output")) ` + "host still injects synthetic surface B terminal text" +Assert-Contract ( + $source -match "(?s)app\.active_surface = surfaceIndex.*?for \(&app\.surfaces" +) "focus callback updates selection after exclusivity" +Assert-Contract (-not $source.Contains("sidebar")) "product sidebar leaked into the gate" +Assert-Contract (-not $source.Contains("canvas")) "product canvas leaked into the gate" +Assert-Contract ( + ([regex]::Matches($source, "winghostty_host_create_surface_v2")).Count -ge 2 +) "host does not describe two complete surfaces" +Assert-Contract ($source.Contains("GetWindowLongPtrW")) ` + "top-level HWND does not own the window state" +Assert-Contract ($source.Contains("TranslateMessage")) ` + "top-level window does not own message translation" +Assert-Contract ( + $source -match "child\.cwd\s*=\s*app\.cwd" +) "zmx attach child does not inherit the gate working directory" +Assert-Contract ($source.Contains("attach-output-timeout")) ` + "zmx attach readiness does not have a bounded timeout" +Assert-Contract (-not $source.Contains("app.tick == 3")) ` + "typed input still depends on a fixed startup tick" +Assert-Contract (-not $source.Contains("app.tick == 6")) ` + "surface recreation still depends on a fixed startup tick" + +$harness = Get-Content -LiteralPath (Join-Path $gateRoot "..\..\..\Tools\windows\terminal-gate.ps1") -Raw +foreach ($token in @( + "zmx send", + "history", + "--vt", + "Assert-ZmxSessionHealthy", + "pwd", + "same-session restart", + "Assert-PinnedCleanWorktree", + "status --porcelain", + "Get-CimInstance", + "Get-ZmxSessionProcessIds", + "RedirectStandardOutput", + "RedirectStandardError", + "NewGuid", + "GRAPHCODE_TERMINAL_SESSION_PREFIX", + "ownedSessionNames", + "GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE", + "cleanup failed", + "exit 0" + )) { + Assert-Contract ($harness.Contains($token)) ` + "smoke harness is missing persistent-session proof: $token" +} +Assert-Contract (-not $harness.Contains("& `$zmx list")) ` + "smoke harness still performs an unbounded zmx list" + +$runner = Get-Content -LiteralPath (Join-Path $gateRoot "..\..\..\Tools\windows\validate.ps1") -Raw +foreach ($token in @( + "terminal-gate.ps1", + "Pinned Windows terminal gate build and smoke", + "GRAPHCODE_WINGHOSTTY_ROOT", + "provider worktrees unavailable", + "real smoke is mandatory" + )) { + Assert-Contract ($runner.Contains($token)) ` + "validation runner is missing real-provider gate handling: $token" +} + +Write-Output "Windows terminal gate contract: PASS" +exit 0 diff --git a/Tools/windows/Tests/TrayDaemon.Tests.ps1 b/Tools/windows/Tests/TrayDaemon.Tests.ps1 new file mode 100644 index 00000000..ec6ba6bd --- /dev/null +++ b/Tools/windows/Tests/TrayDaemon.Tests.ps1 @@ -0,0 +1,111 @@ +[CmdletBinding()] +param( + [string] $Executable +) + +$ErrorActionPreference = "Stop" +$root = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$app = Get-Content (Join-Path $root "graphcode-windows\src\App.zig") -Raw +$supervisor = Get-Content (Join-Path $root "graphcode-windows\src\DaemonSupervisor.zig") -Raw +$daemonMain = Get-Content (Join-Path $root "graphcoded\Sources\main.swift") -Raw +$tray = Get-Content (Join-Path $root "graphcode-windows\src\Tray.zig") -Raw +$package = Get-Content (Join-Path $root "Tools\windows\package.ps1") -Raw + +function Get-PeSubsystem([string] $path) { + $bytes = [IO.File]::ReadAllBytes($path) + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ([Text.Encoding]::ASCII.GetString($bytes, $peOffset, 4) -ne "PE`0`0") { + throw "not a PE image: $path" + } + $optionalOffset = $peOffset + 24 + return [BitConverter]::ToUInt16($bytes, $optionalOffset + 68) +} + +if ($supervisor -notmatch "CREATE_NO_WINDOW") { throw "daemon launch must suppress console creation" } +if ($supervisor -notmatch "graphcoded\.exe") { throw "daemon launch must discover packaged sibling" } +if ($supervisor -notmatch "owned") { throw "daemon ownership state is missing" } +if ($supervisor -notmatch "OpenMutexW" -or $supervisor -notmatch "ERROR_SEM_TIMEOUT") { + throw "daemon startup race coordination is missing" +} +if ($supervisor -notmatch "startup-ready" -or + $supervisor -notmatch "acquireStartupReservation" -or + $supervisor -notmatch "SetEvent") { + throw "daemon startup reservation protocol is missing" +} +if ($supervisor -notmatch "OpenMutexW\(c\.SYNCHRONIZE" -or + $supervisor -notmatch "WaitForSingleObject\(handle, timeout_ms\)" -or + $supervisor -notmatch "acquireStartupReservationBounded") { + throw "daemon startup reservation waiting and recovery are missing" +} +if ($supervisor -notmatch "failed startup competitor releases reservation for owned recovery") { + throw "failed startup competitor recovery coverage is missing" +} +if ($supervisor -notmatch "SetEvent" -or $supervisor -notmatch "forceStop") { + throw "graceful daemon shutdown fallback is missing" +} +if ($supervisor -notmatch "GRAPHCODE_DAEMON_STARTUP_EVENT") { + throw "daemon startup reservation handoff is missing" +} +if ($supervisor -notmatch "GRAPHCODE_DAEMON_HANDOFF_READY_EVENT" -or + $supervisor -notmatch "startup-child-ready" -or + $supervisor -notmatch "waitForChildHandoff" -or + $supervisor -notmatch "cleanupFailedStartup") { + throw "parent must retain and clean the startup reservation through child handoff" +} +if ($daemonMain -notmatch "DaemonStartupHandoff" -or + $daemonMain -notmatch "startupHandoff\.isParentHandoff" -or + $daemonMain -notmatch "onPublished:" -or + $daemonMain -notmatch "startupHandoff\.publish\(\)" -or + $daemonMain -notmatch "recordActiveGeneration\(\)[\s\S]*?WindowsDaemonInstanceLock\(\)") { + throw "child must skip the parent-held reservation and publish readiness after its lifetime lock" +} +$handoffLive = Join-Path $root "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1" +if (-not (Test-Path -LiteralPath $handoffLive) -or + (Get-Content -LiteralPath $handoffLive -Raw) -notmatch + "Concurrent shells did not spawn exactly one graphcoded child") { + throw "concurrent two-shell handoff coverage is missing" +} +if ($app -notmatch "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK" -or + $app -notmatch "DaemonSupervisorState") { + throw "concurrent handoff test observability is missing" +} +if ($app -notmatch "WM_CLOSE[\s\S]*?SW_HIDE") { throw "window close must hide to tray" } +if ($app -notmatch "command_open[\s\S]*?SW_SHOW" -or + $app -notmatch "command_exit[\s\S]*?DestroyWindow") { throw "tray open/exit actions are missing" } +if ($app -notmatch "taskbar_created[\s\S]*?tray\.readd") { throw "TaskbarCreated recovery is missing" } +if ($tray -notmatch "Shell_NotifyIconW" -or + $tray -notmatch "Open GraphCode" -or $tray -notmatch "Exit") { throw "tray shell contract is incomplete" } +if ($tray -notmatch "NIM_SETVERSION" -or + $tray -notmatch "NOTIFYICON_VERSION_4" -or + $tray -notmatch "icon_id") { throw "tray callback identity is incomplete" } +if ($tray -notmatch "TrackPopupMenu" -or + $tray -notmatch "TPM_RIGHTBUTTON" -or + $tray -notmatch "observeTestCallback" -or + $app -notmatch "observeTestCallback[\s\S]*?showMenu") { + throw "tray test observability must use the production callback and popup menu" +} +if ($app -notmatch "test_hook_message[\s\S]*?PostMessageW[\s\S]*?notify_message") { + throw "tray live hook must relay through the production callback message" +} +if ($app -notmatch "callbackTargetsIcon" -or + $app -notmatch "restoreShellWindow") { throw "tray callback routing is incomplete" } +$live = Get-Content (Join-Path $root "Tools\windows\Tests\TrayLive.Tests.ps1") -Raw +if ($live -match "WM_COMMAND" -or + $live -notmatch "GetMenuString" -or + $live -notmatch "GetMenuItemID" -or + $live -notmatch "GetMenuItemRect" -or + $live -notmatch "SendInput") { + throw "tray live context Exit must validate and activate the actual popup item without WM_COMMAND" +} +$mainWindow = Get-Content (Join-Path $root "graphcode-windows\src\MainWindow.zig") -Raw +if ($mainWindow -notmatch "AllowSetForegroundWindow") { + throw "single-instance restore must grant the existing shell foreground permission" +} +if ($package -match 'Set-Content[^`r`n]*graphcode-windows\.exe') { + throw "package must not generate a console launcher script" +} +if ($Executable) { + if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "shell executable is missing" } + if ((Get-PeSubsystem $Executable) -ne 2) { throw "shell executable is not PE GUI subsystem" } +} +Write-Output "Tray daemon contract tests: PASS" diff --git a/Tools/windows/Tests/TrayLive.Tests.ps1 b/Tools/windows/Tests/TrayLive.Tests.ps1 new file mode 100644 index 00000000..a6dd5213 --- /dev/null +++ b/Tools/windows/Tests/TrayLive.Tests.ps1 @@ -0,0 +1,471 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Executable, + [Parameter(Mandatory)] + [string] $PipeName, + [int] $ExternalDaemonPid = 0 +) + +$ErrorActionPreference = "Stop" + +if (-not ("GraphCodeTrayLiveNative" -as [type])) { + Add-Type @" +using System; +using System.Runtime.InteropServices; + +public static class GraphCodeTrayLiveNative { + public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct NotifyIconIdentifier { + public int cbSize; + public IntPtr hWnd; + public uint uID; + public Guid guidItem; + } + [StructLayout(LayoutKind.Sequential)] + public struct Rect { public int left, top, right, bottom; } + [StructLayout(LayoutKind.Sequential)] + public struct Point { public int x, y; } + [StructLayout(LayoutKind.Sequential)] + public struct MouseInput { + public int dx, dy; + public uint mouseData, flags, time; + public UIntPtr extraInfo; + } + [StructLayout(LayoutKind.Sequential)] + public struct Input { + public uint type; + public MouseInput mouse; + } + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] + public static extern bool IsWindow(IntPtr hwnd); + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hwnd); + [DllImport("user32.dll")] + public static extern bool PostMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + public static extern IntPtr SendMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + public static extern uint SendInput(uint count, Input[] inputs, int size); + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] + public static extern IntPtr WindowFromPoint(Point point); + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] + public static extern bool GetMenuItemRect( + IntPtr hwnd, IntPtr menu, uint item, out Rect rect); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr GetProp(IntPtr hwnd, string name); + [DllImport("user32.dll")] + public static extern IntPtr MonitorFromPoint(Point point, uint flags); + [DllImport("user32.dll")] + public static extern uint GetDpiForWindow(IntPtr hwnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr FindWindow(string className, string title); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern uint RegisterWindowMessage(string name); + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetClassName(IntPtr hwnd, System.Text.StringBuilder className, int maxCount); + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId); + [DllImport("user32.dll")] + public static extern int GetMenuItemCount(IntPtr menu); + [DllImport("user32.dll")] + public static extern uint GetMenuItemID(IntPtr menu, int position); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetMenuString( + IntPtr menu, uint item, System.Text.StringBuilder text, int maxCount, uint flags); + [DllImport("shell32.dll")] + public static extern int Shell_NotifyIconGetRect(ref NotifyIconIdentifier identifier, out Rect rect); + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + public static extern bool Shell_NotifyIcon(uint message, ref NotifyIconIdentifier identifier); +} +"@ +} + +$script:NIM_DELETE = 0x0002 +$script:WM_CLOSE = 0x0010 +$script:WM_SYSCOMMAND = 0x0112 +$script:WM_LBUTTONDBLCLK = 0x0203 +$script:WM_CONTEXTMENU = 0x007B +$script:SC_CLOSE = 0xF060 +$script:MOUSEEVENTF_RIGHTDOWN = 0x0008 +$script:MOUSEEVENTF_RIGHTUP = 0x0010 +$script:MOUSEEVENTF_MOVE = 0x0001 +$script:MOUSEEVENTF_ABSOLUTE = 0x8000 +$script:MOUSEEVENTF_VIRTUALDESK = 0x4000 +$script:MF_BYPOSITION = 0x0400 +$script:MONITOR_DEFAULTTONULL = 0 +$script:TRAY_OPEN = 1 +$script:TRAY_CONTEXT = 2 +$script:TRAY_MENU = 3 +$script:TRAY_OPEN_COMMAND = 0x5001 +$script:TRAY_EXIT_COMMAND = 0x5002 + +function Get-ShellWindow([int] $processId) { + $script:foundWindow = [IntPtr]::Zero + $callback = [GraphCodeTrayLiveNative+EnumWindowsProc]{ + param($hwnd, $unused) + [uint32]$owner = 0 + [void][GraphCodeTrayLiveNative]::GetWindowThreadProcessId($hwnd, [ref]$owner) + if ($owner -eq $processId) { + $className = New-Object Text.StringBuilder 128 + [void][GraphCodeTrayLiveNative]::GetClassName($hwnd, $className, $className.Capacity) + if ($className.ToString() -eq "GraphCodeWindowsShell") { + $script:foundWindow = $hwnd + return $false + } + } + return $true + } + [void][GraphCodeTrayLiveNative]::EnumWindows($callback, [IntPtr]::Zero) + return $script:foundWindow +} + +function Assert-TrayIcon([IntPtr] $hwnd) { + $id = [GraphCodeTrayLiveNative+NotifyIconIdentifier]::new() + $id.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($id) + $id.hWnd = $hwnd + $id.uID = 1 + $rect = [GraphCodeTrayLiveNative+Rect]::new() + $result = [GraphCodeTrayLiveNative]::Shell_NotifyIconGetRect([ref]$id, [ref]$rect) + if ($result -ne 0 -or $rect.right -le $rect.left -or $rect.bottom -le $rect.top) { + throw "GraphCode tray icon was not discoverable through Shell_NotifyIconGetRect" + } + return $rect +} + +function Wait-PhysicalTrayIcon([IntPtr] $hwnd) { + for ($i = 0; $i -lt 30; $i++) { + try { + $rect = Assert-TrayIcon $hwnd + $point = [GraphCodeTrayLiveNative+Point]::new() + $point.x = [int](($rect.left + $rect.right) / 2) + $point.y = [int](($rect.top + $rect.bottom) / 2) + if ([GraphCodeTrayLiveNative]::FindWindow("Shell_TrayWnd", $null) -eq [IntPtr]::Zero) { + throw "Shell_TrayWnd is unavailable for tray icon discovery" + } + if ([GraphCodeTrayLiveNative]::MonitorFromPoint($point, $script:MONITOR_DEFAULTTONULL) -eq [IntPtr]::Zero) { + throw "Shell_NotifyIconGetRect returned coordinates outside every monitor" + } + if ([GraphCodeTrayLiveNative]::GetDpiForWindow($hwnd) -eq 0) { + throw "GraphCode shell has no effective DPI for physical tray coordinates" + } + return $rect + } catch { + if ($i -eq 29) { throw } + Start-Sleep -Milliseconds 100 + } + } +} + +function Invoke-WindowClose([IntPtr] $hwnd) { + [void][GraphCodeTrayLiveNative]::SendMessage( + $hwnd, $script:WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) +} + +function Invoke-CaptionClose([IntPtr] $hwnd) { + [void][GraphCodeTrayLiveNative]::SendMessage( + $hwnd, $script:WM_SYSCOMMAND, [IntPtr]$script:SC_CLOSE, [IntPtr]::Zero) +} + +function Invoke-TrayCallback([IntPtr] $hwnd, [ValidateSet("Open", "Context")] [string] $action) { + $message = [GraphCodeTrayLiveNative]::RegisterWindowMessage("GraphCode.Windows.TrayTestHook") + if ($message -eq 0) { throw "Could not register GraphCode tray test hook" } + $event = if ($action -eq "Open") { $script:TRAY_OPEN } else { $script:TRAY_CONTEXT } + if (-not [GraphCodeTrayLiveNative]::PostMessage( + $hwnd, [int]$message, [IntPtr]$event, [IntPtr]::Zero)) { + throw "Could not dispatch GraphCode tray test hook" + } +} + +function Get-TrayMenu([IntPtr] $hwnd) { + $message = [GraphCodeTrayLiveNative]::RegisterWindowMessage("GraphCode.Windows.TrayTestHook") + if ($message -eq 0) { throw "Could not register GraphCode tray test hook" } + $menu = [GraphCodeTrayLiveNative]::SendMessage( + $hwnd, [int]$message, [IntPtr]$script:TRAY_MENU, [IntPtr]::Zero) + if ($menu -eq [IntPtr]::Zero) { throw "Tray test hook did not expose its popup menu" } + return $menu +} + +function Assert-TrayMenuContract([IntPtr] $menu) { + if ([GraphCodeTrayLiveNative]::GetMenuItemCount($menu) -ne 2) { + throw "Tray popup menu did not contain exactly Open and Exit" + } + $openLabel = New-Object Text.StringBuilder 128 + $exitLabel = New-Object Text.StringBuilder 128 + [void][GraphCodeTrayLiveNative]::GetMenuString( + $menu, 0, $openLabel, $openLabel.Capacity, $script:MF_BYPOSITION) + [void][GraphCodeTrayLiveNative]::GetMenuString( + $menu, 1, $exitLabel, $exitLabel.Capacity, $script:MF_BYPOSITION) + if ($openLabel.ToString() -ne "Open GraphCode" -or $exitLabel.ToString() -ne "Exit" -or + [GraphCodeTrayLiveNative]::GetMenuItemID($menu, 0) -ne $script:TRAY_OPEN_COMMAND -or + [GraphCodeTrayLiveNative]::GetMenuItemID($menu, 1) -ne $script:TRAY_EXIT_COMMAND) { + throw "Tray popup labels or command identities did not match production" + } +} + +function Wait-TrayPopup([int] $processId) { + for ($i = 0; $i -lt 30; $i++) { + $script:trayPopup = [IntPtr]::Zero + $callback = [GraphCodeTrayLiveNative+EnumWindowsProc]{ + param($hwnd, $unused) + $class = New-Object Text.StringBuilder 128 + [void][GraphCodeTrayLiveNative]::GetClassName($hwnd, $class, $class.Capacity) + [uint32]$owner = 0 + [void][GraphCodeTrayLiveNative]::GetWindowThreadProcessId($hwnd, [ref]$owner) + if ([GraphCodeTrayLiveNative]::IsWindowVisible($hwnd) -and + $owner -eq $processId -and $class.ToString() -eq "#32768") { + $script:trayPopup = $hwnd + return $false + } + return $true + } + [void][GraphCodeTrayLiveNative]::EnumWindows($callback, [IntPtr]::Zero) + if ($script:trayPopup -ne [IntPtr]::Zero) { return $script:trayPopup } + Start-Sleep -Milliseconds 100 + } + throw "Tray context callback did not create a visible popup menu window" +} + +function Invoke-PhysicalExit([IntPtr] $popup, [int] $x, [int] $y) { + if (-not [GraphCodeTrayLiveNative]::SetCursorPos($x, $y - 12)) { + throw "Could not prime pointer tracking for the visible tray Exit item" + } + Start-Sleep -Milliseconds 100 + $left = [GraphCodeTrayLiveNative]::GetSystemMetrics(76) + $top = [GraphCodeTrayLiveNative]::GetSystemMetrics(77) + $width = [GraphCodeTrayLiveNative]::GetSystemMetrics(78) + $height = [GraphCodeTrayLiveNative]::GetSystemMetrics(79) + if ($width -le 1 -or $height -le 1) { throw "Could not determine virtual desktop bounds" } + $move = [GraphCodeTrayLiveNative+Input]::new() + $move.type = 0 + $move.mouse.dx = [int](($x - $left) * 65535 / ($width - 1)) + $move.mouse.dy = [int](($y - $top) * 65535 / ($height - 1)) + $move.mouse.flags = $script:MOUSEEVENTF_MOVE -bor + $script:MOUSEEVENTF_ABSOLUTE -bor $script:MOUSEEVENTF_VIRTUALDESK + $moveInputs = [GraphCodeTrayLiveNative+Input[]]@($move) + $inputSize = [Runtime.InteropServices.Marshal]::SizeOf($move) + if ($inputSize -ne 40) { throw "SendInput mouse INPUT layout was $inputSize bytes, not 40" } + if ([GraphCodeTrayLiveNative]::SendInput(1, $moveInputs, $inputSize) -ne 1) { + throw "SendInput could not move to the visible tray Exit item" + } + Start-Sleep -Milliseconds 100 + $point = [GraphCodeTrayLiveNative+Point]::new() + $point.x = $x + $point.y = $y + if ([GraphCodeTrayLiveNative]::WindowFromPoint($point) -ne $popup) { + throw "DPI-correct Exit point did not target the active tray popup" + } + $input = [GraphCodeTrayLiveNative+Input]::new() + $input.type = 0 + $input.mouse.flags = $script:MOUSEEVENTF_RIGHTDOWN + $inputs = [GraphCodeTrayLiveNative+Input[]]@($input) + if ([GraphCodeTrayLiveNative]::SendInput(1, $inputs, $inputSize) -ne 1) { + throw "SendInput could not press the visible tray Exit item" + } + Start-Sleep -Milliseconds 100 + $input.mouse.flags = $script:MOUSEEVENTF_RIGHTUP + $inputs = [GraphCodeTrayLiveNative+Input[]]@($input) + if ([GraphCodeTrayLiveNative]::SendInput(1, $inputs, $inputSize) -ne 1) { + throw "SendInput could not activate the visible tray Exit item" + } +} + +function Invoke-AccessibleExit([int] $x, [int] $y) { + Add-Type -AssemblyName UIAutomationClient -ErrorAction Stop + Add-Type -AssemblyName UIAutomationTypes -ErrorAction Stop + Add-Type -AssemblyName WindowsBase -ErrorAction Stop + for ($i = 0; $i -lt 30; $i++) { + $element = [System.Windows.Automation.AutomationElement]::FromPoint( + [System.Windows.Point]::new([double]$x, [double]$y)) + if ($element -and $element.Current.Name -eq "Exit" -and + $element.Current.ControlType -eq [System.Windows.Automation.ControlType]::MenuItem) { + $pattern = $element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + ([System.Windows.Automation.InvokePattern]$pattern).Invoke() + return $true + } + Start-Sleep -Milliseconds 100 + } + return $false +} + +function Invoke-PopupKeyboardFallback([IntPtr] $popup) { + if (-not [GraphCodeTrayLiveNative]::PostMessage( + $popup, 0x0100, [IntPtr]0x23, [IntPtr]::Zero) -or + -not [GraphCodeTrayLiveNative]::PostMessage( + $popup, 0x0101, [IntPtr]0x23, [IntPtr]::Zero) -or + -not [GraphCodeTrayLiveNative]::PostMessage( + $popup, 0x0100, [IntPtr]0x0D, [IntPtr]::Zero) -or + -not [GraphCodeTrayLiveNative]::PostMessage( + $popup, 0x0101, [IntPtr]0x0D, [IntPtr]::Zero)) { + throw "Could not dispatch the visible tray Exit keyboard interaction" + } +} + +function Wait-WindowVisibility([IntPtr] $hwnd, [bool] $visible, [string] $label) { + for ($i = 0; $i -lt 30; $i++) { + if (-not [GraphCodeTrayLiveNative]::IsWindow($hwnd)) { + throw "$label destroyed the shell HWND" + } + if ([GraphCodeTrayLiveNative]::IsWindowVisible($hwnd) -eq $visible) { return } + Start-Sleep -Milliseconds 100 + } + throw "$label did not set shell visibility to $visible" +} + +function Wait-ShellForeground([IntPtr] $hwnd, [string] $label) { + for ($i = 0; $i -lt 30; $i++) { + if ([GraphCodeTrayLiveNative]::GetForegroundWindow() -eq $hwnd) { return } + Start-Sleep -Milliseconds 100 + } + throw "$label did not foreground the shell" +} + +function Wait-TrayCallback([IntPtr] $owner, [uint32] $event) { + $observed = [IntPtr]::Zero + for ($i = 0; $i -lt 30; $i++) { + $observed = [GraphCodeTrayLiveNative]::GetProp( + $owner, "GraphCode.Windows.TrayTestCallback") + if ($observed.ToInt64() -eq $event) { return } + Start-Sleep -Milliseconds 100 + } + throw "Tray test hook did not reach notification callback $event; observed $($observed.ToInt64())" +} + +function Assert-NoConsoleWindow([int] $processId) { + $script:found = $false + $callback = [GraphCodeTrayLiveNative+EnumWindowsProc]{ + param($hwnd, $unused) + [uint32]$owner = 0 + [void][GraphCodeTrayLiveNative]::GetWindowThreadProcessId($hwnd, [ref]$owner) + if ($owner -eq $processId) { + $class = New-Object Text.StringBuilder 128 + [void][GraphCodeTrayLiveNative]::GetClassName($hwnd, $class, $class.Capacity) + if ($class.ToString() -eq "ConsoleWindowClass") { $script:found = $true } + } + return $true + } + [void][GraphCodeTrayLiveNative]::EnumWindows($callback, [IntPtr]::Zero) + if ($script:found) { throw "GUI shell created a console window" } +} + +$oldPipe = [Environment]::GetEnvironmentVariable("GRAPHCODE_DAEMON_PIPE") +$oldRequire = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_REQUIRE_DAEMON") +$oldTrayTestHook = [Environment]::GetEnvironmentVariable("GRAPHCODE_TRAY_TEST_HOOK") +$process = $null +try { + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$PipeName" + $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = "0" + $env:GRAPHCODE_TRAY_TEST_HOOK = "1" + $process = Start-Process -FilePath $Executable -PassThru + $hwnd = [IntPtr]::Zero + for ($i = 0; $i -lt 60 -and $hwnd -eq [IntPtr]::Zero; $i++) { + Start-Sleep -Milliseconds 100 + $hwnd = Get-ShellWindow $process.Id + } + if ($hwnd -eq [IntPtr]::Zero) { throw "GraphCode GUI window did not start" } + for ($i = 0; $i -lt 20 -and -not [GraphCodeTrayLiveNative]::IsWindowVisible($hwnd); $i++) { + Start-Sleep -Milliseconds 100 + } + if (-not [GraphCodeTrayLiveNative]::IsWindowVisible($hwnd)) { + throw "GraphCode GUI window did not become visible" + } + $script:trayWindow = $hwnd + Assert-NoConsoleWindow $process.Id + $trayRect = Wait-PhysicalTrayIcon $hwnd + + $racer = Start-Process -FilePath $Executable -PassThru + if (-not $racer.WaitForExit(5000)) { throw "Competing shell start did not complete" } + if ($racer.ExitCode -ne 0) { throw "Competing shell start exited with code $($racer.ExitCode)" } + Start-Sleep -Milliseconds 250 + if (-not [GraphCodeTrayLiveNative]::IsWindowVisible($hwnd)) { + throw "Competing shell start did not preserve the existing window" + } + + Invoke-WindowClose $hwnd + Wait-WindowVisibility $hwnd $false "WM_CLOSE" + $trayRect = Wait-PhysicalTrayIcon $hwnd + + Invoke-TrayCallback $hwnd "Open" + Wait-TrayCallback $hwnd $script:WM_LBUTTONDBLCLK + Wait-WindowVisibility $hwnd $true "Tray callback Open" + Wait-ShellForeground $hwnd "Tray callback Open" + + Invoke-CaptionClose $hwnd + Wait-WindowVisibility $hwnd $false "Caption close command" + Invoke-TrayCallback $hwnd "Open" + Wait-TrayCallback $hwnd $script:WM_LBUTTONDBLCLK + Wait-WindowVisibility $hwnd $true "Second tray callback Open" + Wait-ShellForeground $hwnd "Second tray callback Open" + + Invoke-WindowClose $hwnd + Wait-WindowVisibility $hwnd $false "Hidden single-instance restore setup" + $second = Start-Process -FilePath $Executable -PassThru + if (-not $second.WaitForExit(5000)) { throw "Second launch did not return after requesting restore" } + if ($second.ExitCode -ne 0) { throw "Second launch exited with code $($second.ExitCode)" } + Wait-WindowVisibility $hwnd $true "Second launch" + Wait-ShellForeground $hwnd "Second launch" + + $icon = [GraphCodeTrayLiveNative+NotifyIconIdentifier]::new() + $icon.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($icon) + $icon.hWnd = $hwnd + $icon.uID = 1 + if (-not [GraphCodeTrayLiveNative]::Shell_NotifyIcon($script:NIM_DELETE, [ref]$icon)) { + throw "Could not remove tray icon for Explorer-loss simulation" + } + $taskbar = [GraphCodeTrayLiveNative]::RegisterWindowMessage("TaskbarCreated") + if ($taskbar -eq 0) { throw "Could not register TaskbarCreated" } + [void][GraphCodeTrayLiveNative]::PostMessage($hwnd, [int]$taskbar, [IntPtr]::Zero, [IntPtr]::Zero) + $trayRect = Wait-PhysicalTrayIcon $hwnd + + $menu = Get-TrayMenu $hwnd + Assert-TrayMenuContract $menu + Invoke-TrayCallback $hwnd "Context" + Wait-TrayCallback $hwnd $script:WM_CONTEXTMENU + $popup = Wait-TrayPopup $process.Id + $exitRect = [GraphCodeTrayLiveNative+Rect]::new() + if (-not [GraphCodeTrayLiveNative]::GetMenuItemRect( + $popup, $menu, 1, [ref]$exitRect)) { + throw "Could not locate the visible tray Exit item bounds" + } + if ($exitRect.right -le $exitRect.left -or $exitRect.bottom -le $exitRect.top) { + throw "Visible tray Exit item bounds were invalid" + } + $exitPoint = [GraphCodeTrayLiveNative+Point]::new() + $exitPoint.x = [int](($exitRect.left + $exitRect.right) / 2) + $exitPoint.y = $exitRect.bottom - 2 + $accessibleExit = Invoke-AccessibleExit $exitPoint.x $exitPoint.y + if (-not $accessibleExit) { + Invoke-PhysicalExit $popup $exitPoint.x $exitPoint.y + } + if (-not $process.WaitForExit(250)) { + Invoke-PopupKeyboardFallback $popup + } + if (-not $process.WaitForExit(5000)) { + throw "Tray Exit command did not terminate the shell" + } + if ($ExternalDaemonPid -and -not (Get-Process -Id $ExternalDaemonPid -ErrorAction SilentlyContinue)) { + throw "External daemon was terminated by tray Exit" + } + Write-Output "Tray live executable tests: PASS" +} finally { + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + if ($null -eq $oldPipe) { Remove-Item Env:GRAPHCODE_DAEMON_PIPE -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_DAEMON_PIPE = $oldPipe } + if ($null -eq $oldRequire) { Remove-Item Env:GRAPHCODE_SHELL_REQUIRE_DAEMON -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = $oldRequire } + if ($null -eq $oldTrayTestHook) { Remove-Item Env:GRAPHCODE_TRAY_TEST_HOOK -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_TRAY_TEST_HOOK = $oldTrayTestHook } +} diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 new file mode 100644 index 00000000..f1627e2a --- /dev/null +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -0,0 +1,160 @@ +$ErrorActionPreference = "Stop" + +$runner = Join-Path $PSScriptRoot "..\validate.ps1" +if (-not (Test-Path $runner)) { + throw "RED: validation runner does not exist at $runner" +} + +$tasks = & $runner -List +$expected = @( + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "remote-bridge", + "remote-e2e", + "swift-format", + "visual-baseline", + "tdd-evidence", + "privacy", + "terminal-gate", + "windows-shell", + "packaging", + "hardening" +) +foreach ($task in $expected) { + if ($tasks -notcontains $task) { + throw "Validation task '$task' is missing" + } +} + +$dryRun = & $runner -Task swift-paths -DryRun +if ($LASTEXITCODE -ne 0) { + throw "Dry run failed with exit code $LASTEXITCODE" +} +if (($dryRun -join "`n") -notmatch "swift-paths") { + throw "Dry run did not name the selected task" +} + +$pwsh = (Get-Process -Id $PID).Path +& $pwsh -NoProfile -File $runner -Task not-a-task *> $null +if ($LASTEXITCODE -eq 0) { + throw "An unknown validation task succeeded" +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$untrackedDirectory = Join-Path $repoRoot "investigation\spikes\validation-runner-untracked" +New-Item -ItemType Directory -Force $untrackedDirectory | Out-Null +try { + "let value=1" | Set-Content (Join-Path $untrackedDirectory "Unformatted.swift") + & $pwsh -NoProfile -File $runner -Task swift-format *> $null + if ($LASTEXITCODE -eq 0) { + throw "An unformatted untracked Swift source was ignored" + } +} finally { + Remove-Item -LiteralPath $untrackedDirectory -Recurse -Force +} + +$foreignJunction = Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\OwnershipSentinel" +New-Item -ItemType Directory -Force $foreignJunction | Out-Null +try { + & $runner -Task swift-format -DryRun *> $null + if (-not (Test-Path $foreignJunction)) { + throw "A validation task removed resources owned by another task" + } + + $windowsWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-hardening.yml") -Raw + if ($windowsWorkflow -notmatch "(?s)full-pinned:.*bootstrap\.ps1.*validate\.ps1 -Task all.*Hardening\.Tests\.ps1 -Environment") { + throw "RED: full-pinned Windows CI does not run real hardening after provider setup" + } + if ($windowsWorkflow -notmatch "GRAPHCODE_HARDENING_TARGET") { + throw "RED: full-pinned Windows CI does not provide an owned environment harness" + } + $hardeningSource = Get-Content (Join-Path $PSScriptRoot "Hardening.Tests.ps1") -Raw + if ($hardeningSource -notmatch + '(?s)\$shellVersion\s*=\s*\(& \$shell --version.*?-Version \$shellVersion') { + throw "RED: post-release hardening does not preserve the built shell version" + } + $windowsShellWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\windows-shell.yml") -Raw + $windowsPortWorkflow = Get-Content ` + (Join-Path $repoRoot ".github\workflows\windows-port-validation.yml") -Raw + foreach ($workflow in @($windowsWorkflow, $windowsShellWorkflow, $windowsPortWorkflow)) { + if ($workflow -notmatch + "compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6" -or + $workflow -notmatch "swift-version: swift-6\.3\.3-release" -or + $workflow -notmatch "swift-build: 6\.3\.3-RELEASE") { + throw "RED: pinned Windows CI does not install Swift 6.3.3 without WinGet" + } + } + if ($windowsShellWorkflow -notmatch "bootstrap\.ps1") { + throw "RED: Windows shell CI does not bootstrap exact dependencies" + } + if ($windowsShellWorkflow -notmatch "validate\.ps1 -Task windows-shell -SkipTrayLive" -or + $windowsPortWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "validate\.ps1 -Task all -SkipTrayLive -SkipWslRemoteE2E" -or + $windowsWorkflow -notmatch "Hardening\.Tests\.ps1 -Environment -SkipTrayLive") { + throw "RED: hosted Windows CI does not explicitly declare unsupported interactive or WSL fixtures" + } + if ($windowsShellWorkflow -notmatch "Tools/windows/uia-live-gate\.ps1") { + throw "RED: Windows shell CI does not include the UI Automation live gate" + } + $runnerSource = Get-Content $runner -Raw + if ($runnerSource -notmatch '(?s)Pinned GraphCode Windows shell build and smoke.*?Native UI Automation live gate.*?uia-live-gate\.ps1') { + throw "RED: Windows shell validation does not execute the UI Automation live gate" + } + if ($runnerSource -notmatch '\$SkipWslRemoteE2E' -or + $runnerSource -notmatch '"--skip-local-wsl"') { + throw "RED: hosted validation cannot explicitly isolate unavailable local WSL fixtures" + } + $privacyRaceSource = Get-Content ` + (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") -Raw + if ($privacyRaceSource -notmatch '\$AvailableProcessorCount = \[Environment\]::ProcessorCount' -or + $privacyRaceSource -notmatch '\$remoteProcessCount = 1' -or + $privacyRaceSource -notmatch '\[Math\]::Min\(24, \[Math\]::Max\(4, \$processorCount \* 2\)\)' -or + $privacyRaceSource -notmatch 'GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER = "3"') { + throw "RED: remote bridge privacy race does not scale bounded concurrency to runner capacity" + } + if ($windowsWorkflow -notmatch "(?s)environment:.*Hardening\.Tests\.ps1 -Environment -SchemaOnly") { + throw "RED: environment CI does not invoke the exact schema-only hardening contract" + } + $macWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\macos-shared-regression.yml") -Raw + if ($macWorkflow -notmatch "brew install mise" -or + $macWorkflow -notmatch "mise install" -or + $macWorkflow -notmatch "mise exec -- make test") { + throw "RED: macOS CI does not install and execute pinned mise.toml tools" + } +} finally { + Remove-Item -LiteralPath $foreignJunction -Recurse -Force -ErrorAction SilentlyContinue +} + +$oldWinghosttyRoot = [Environment]::GetEnvironmentVariable( + "GRAPHCODE_WINGHOSTTY_ROOT" +) +$oldZmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") +try { + $env:GRAPHCODE_WINGHOSTTY_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-winghostty-provider" + $env:GRAPHCODE_ZMX_ROOT = Join-Path $repoRoot ` + "investigation\spikes\missing-zmx-provider" + & $pwsh -NoProfile -File $runner -Task terminal-gate *> $null + if ($LASTEXITCODE -eq 0) { + throw "terminal-gate passed without its pinned providers" + } +} finally { + if ($null -eq $oldWinghosttyRoot) { + Remove-Item Env:GRAPHCODE_WINGHOSTTY_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_WINGHOSTTY_ROOT = $oldWinghosttyRoot + } + if ($null -eq $oldZmxRoot) { + Remove-Item Env:GRAPHCODE_ZMX_ROOT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_ZMX_ROOT = $oldZmxRoot + } +} + +Write-Host "ValidationRunner.Tests.ps1: PASS" +exit 0 diff --git a/Tools/windows/Tests/VisualBaseline.Tests.ps1 b/Tools/windows/Tests/VisualBaseline.Tests.ps1 new file mode 100644 index 00000000..dea0329d --- /dev/null +++ b/Tools/windows/Tests/VisualBaseline.Tests.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$validator = Join-Path $repoRoot "Tools\windows\visual-baseline.ps1" + +if (-not (Test-Path -LiteralPath $validator)) { + throw "RED: visual baseline validator is missing at $validator" +} + +$output = & $validator +if ($LASTEXITCODE -ne 0) { + throw "Visual baseline validation failed with exit code $LASTEXITCODE" +} +if (($output -join "`n") -notmatch "Visual baseline: PASS") { + throw "Visual baseline validator did not report PASS" +} + +Write-Host "VisualBaseline.Tests.ps1: PASS" +exit 0 diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 new file mode 100644 index 00000000..7a26adbf --- /dev/null +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -0,0 +1,357 @@ +[CmdletBinding()] +param( + [switch] $List, + [string] $ZigExecutable +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$shellRoot = Join-Path $repoRoot "graphcode-windows" +$shellScript = Join-Path $repoRoot "Tools\windows\windows-shell.ps1" + +if ($List) { + @( + "app-lifecycle", + "daemon-reconnect", + "protocol-correlation", + "graph-decoding", + "terminal-lifecycle", + "two-surfaces", + "cleanup" + ) + exit 0 +} + +function Assert-Contract([object] $condition, [string] $message) { + $values = @($condition) + if ($values.Count -ne 1 -or -not [bool] $values[0]) { + throw "Windows shell contract: $message" + } +} + +$shellSource = Get-Content $shellScript -Raw +$appSource = Get-Content (Join-Path $shellRoot "src\App.zig") -Raw +$mainWindowSource = Get-Content (Join-Path $shellRoot "src\MainWindow.zig") -Raw +$nativeFormsSource = Get-Content (Join-Path $shellRoot "src\NativeForms.zig") -Raw +$inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw +if ($shellSource -match '(?m)^\s*Write-OwnedResourceMetrics\s*$') { + throw "Windows shell contract: empty resource metric phase" +} +Assert-Contract ($shellSource -match '(?s)\$inputApp\s*=\s*Start-Process.*?\$inputApp\.Id.*?Write-OwnedResourceMetrics "windows-shell:large-paste" @\(\$inputApp\.Id\)') ` + "large-paste metric is not tied to the recorded inputApp PID" +Assert-Contract ($shellSource -match '(?s)GraphCode Windows shell restart smoke.*?Invoke-ShellProcess \$arguments "windows-shell:restart"') ` + "restart snapshot is not assigned the restart phase" +$restartBlock = [regex]::Match($shellSource, + '(?s)GraphCode Windows shell restart smoke.*?Invoke-ShellProcess \$arguments "windows-shell:restart".*?\r?\n\s*}') +Assert-Contract ($restartBlock.Success -and $restartBlock.Value -notmatch 'windows-shell:large-paste') ` + "restart path can satisfy large-paste phase" +Assert-Contract ($mainWindowSource -match 'Project Worktree Policy' -and + $appSource -match '\.edit_worktree_policy => app\.handleAction\(\.edit_worktree_policy\)' -and + $appSource -match 'NativeForms\.worktreePolicy') ` + "worktree policy editor is not reachable from the native shell" +Assert-Contract ($nativeFormsSource -match 'BS_AUTORADIOBUTTON' -and + $nativeFormsSource -match 'Remove: automatically remove safe landed worktrees' -and + $nativeFormsSource -match 'notice_size_gb' -and + $nativeFormsSource -match 'notice_count') ` + "project settings does not expose resolve choices and notice thresholds" +Assert-Contract ($nativeFormsSource -match 'worktreeSweep' -and + $nativeFormsSource -match 'SAFE TO REMOVE' -and + $nativeFormsSource -match 'LOOK BEFORE REMOVING' -and + $nativeFormsSource -match 'Remove Selected') ` + "dedicated Worktree Sweep sheet is missing its safety tiers or removal action" +Assert-Contract ($inputSource -match "ctrl and shift and key == 'I'.*inspect_worktrees") ` + "Inspect worktrees is not routed from Ctrl+Shift+I" + +function Invoke-Native([string] $description, [scriptblock] $command) { + Write-Host "==> $description" + & $command + if ($LASTEXITCODE -ne 0) { + throw "$description failed with exit code $LASTEXITCODE" + } +} + +function Resolve-TestZig { + if ($ZigExecutable -and (Test-Path -LiteralPath $ZigExecutable -PathType Leaf)) { + return (Resolve-Path -LiteralPath $ZigExecutable).Path + } + $command = Get-Command zig.exe -ErrorAction SilentlyContinue + if ($command -and (Test-Path -LiteralPath $command.Source -PathType Leaf)) { + & $command.Source env *> $null + if ($LASTEXITCODE -eq 0) { + return $command.Source + } + } + throw "A working Zig executable is required for executable Windows shell tests." +} + +foreach ($path in @( + "build.zig", + "build.zig.zon", + "provider-pins.json", + "package-metadata.json", + "README.md", + "src\main.zig", + "src\App.zig", + "src\MainWindow.zig", + "src\DaemonClient.zig", + "src\GraphModel.zig", + "src\GraphCanvas.zig", + "src\CanvasLayoutStore.zig", + "src\CanvasInput.zig", + "src\Sidebar.zig", + "src\TerminalWorkspace.zig", + "src\TerminalSurface.zig", + "src\WorkspaceLayout.zig", + "src\InputRouter.zig", + "src\Forms.zig", + "src\NativeForms.zig", + "src\WindowsOnboarding.zig", + "src\WindowsProductSettings.zig", + "src\Accessibility.zig", + "src\DesignTokens.zig", + "src\Wire.zig", + "src\FrameBuffer.zig", + "..\Tools\windows\Stub-Daemon.ps1", + "fixtures\daemon-v2-hello.json", + "fixtures\daemon-v2-list-projects.json", + "fixtures\daemon-v2-subscribe.json", + "fixtures\daemon-v2-graph-event.json", + "fixtures\daemon-v2-graph-reordered-edges.json", + "fixtures\daemon-v2-presence-event.json", + "fixtures\daemon-v2-graph-attention.json", + "fixtures\daemon-v1-list-projects.json", + "fixtures\daemon-v2-create-node.json", + "fixtures\daemon-v2-create-edge.json", + "fixtures\daemon-v2-delete-edge.json", + "fixtures\daemon-v2-message-node.json", + "fixtures\daemon-v2-stop-node.json", + "fixtures\sidebar-recent-projects.json" + )) { + Assert-Contract (Test-Path -LiteralPath (Join-Path $shellRoot $path)) ` + "required scaffold file is missing: $path" +} + +$pins = Get-Content -LiteralPath (Join-Path $shellRoot "provider-pins.json") -Raw | + ConvertFrom-Json +Assert-Contract ($pins.schemaVersion -eq 1) "provider pin schema is not 1" +Assert-Contract ($pins.winghostty.sha -eq + "f5abc059e4ca58b376eb209313aca7784659c679") "Winghostty pin changed" +Assert-Contract ($pins.zmx.sha -eq + "029e11d2b19162fb3bdf90c8270237d303b8bfb4") "zmx pin changed" +Assert-Contract ($pins.winghostty.remoteUrl -eq + "https://github.com/coneilen/winghostty.git") "Winghostty remote URL changed" +Assert-Contract ($pins.zmx.remoteUrl -eq + "https://github.com/coneilen/zmx.git") "zmx remote URL changed" +Assert-Contract (-not $pins.localFallback.enabled) "local provider fallback remains enabled" +Assert-Contract (-not $pins.localFallback.remoteWorkflowBlocked) ` + "remote provider workflow remains blocked" + +$metadata = Get-Content -LiteralPath (Join-Path $shellRoot "package-metadata.json") -Raw | + ConvertFrom-Json +Assert-Contract ($metadata.installer -eq $true) "installer metadata is not enabled" +Assert-Contract ($metadata.executable -eq "graphcode-windows.exe") ` + "package metadata does not identify the shell" + +$hello = Get-Content -LiteralPath (Join-Path $shellRoot "fixtures\daemon-v2-hello.json") -Raw | + ConvertFrom-Json +Assert-Contract ($hello.version -eq 2) "v2 hello fixture has the wrong version" +Assert-Contract ($hello.supportedVersions -contains 1 -and $hello.supportedVersions -contains 2) ` + "v2 hello fixture does not advertise both protocol versions" +Assert-Contract ($hello.PSObject.Properties.Name -notcontains "subscription") ` + "all-project hello fixture must omit the subscription filter" + +$subscribe = Get-Content ` + -LiteralPath (Join-Path $shellRoot "fixtures\daemon-v2-subscribe.json") -Raw | + ConvertFrom-Json +Assert-Contract (@($subscribe.subscription.projectPaths).Count -eq 1) ` + "project subscription fixture does not contain exactly one project" + +$create = Get-Content ` + -LiteralPath (Join-Path $shellRoot "fixtures\daemon-v2-create-node.json") -Raw | + ConvertFrom-Json +$draft = $create.command.graphCommand.command.createNode._0 +Assert-Contract ($draft.id -and $draft.title -and $draft.firstInstruction) ` + "create-node fixture is not a complete draft" +Assert-Contract ($draft.loopType -eq "turnBased" -and $draft.backend -eq "claudeCode") ` + "create-node fixture does not use a valid turn-based draft" +Assert-Contract ($draft.pausesBeforeWritesOnly -is [bool]) ` + "create-node fixture omitted pausesBeforeWritesOnly" + +$message = Get-Content ` + -LiteralPath (Join-Path $shellRoot "fixtures\daemon-v2-message-node.json") -Raw | + ConvertFrom-Json +Assert-Contract ($message.command.graphCommand.command.messageNode._0 -and + $message.command.graphCommand.command.messageNode.text -and + $null -eq $message.command.graphCommand.command.messageNode.from) ` + "message-node fixture does not match Codable payload shape" + +$stop = Get-Content ` + -LiteralPath (Join-Path $shellRoot "fixtures\daemon-v2-stop-node.json") -Raw | + ConvertFrom-Json +Assert-Contract ($stop.command.graphCommand.command.stopNode._0) ` + "stop-node fixture does not match Codable payload shape" + +$mainWindowSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\MainWindow.zig") -Raw +$callbackIndex = $mainWindowSource.IndexOf("if (value.callback) |callback|") +$defaultIndex = $mainWindowSource.IndexOf( + "result = c.DefWindowProcW(hwnd, message, wparam, lparam);", + $callbackIndex +) +Assert-Contract ($callbackIndex -ge 0 -and $defaultIndex -gt $callbackIndex) ` + "window messages must reach GraphCode before DefWindowProc handles unclaimed messages" + +$appSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\App.zig") -Raw +Assert-Contract ($appSource -match "GraphCanvas\.paint[\s\S]+workspace\.paintChrome\(hdc\)") ` + "WM_PAINT must render both the GraphCode canvas and terminal workspace chrome" + +$zig = Resolve-TestZig +Invoke-Native "Wire executable tests" { + Push-Location $shellRoot + try { & $zig test src\Wire.zig } finally { Pop-Location } +} +Invoke-Native "Forms and navigation executable tests" { + Push-Location $shellRoot + try { & $zig test src\Forms.zig } finally { Pop-Location } +} +Invoke-Native "Native dialog message-loop 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" + Push-Location $shellRoot + try { + & $zig test src\NativeForms.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Jump palette 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" + Push-Location $shellRoot + try { + & $zig test src\JumpPalette.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Onboarding 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" + Push-Location $shellRoot + try { + & $zig test src\WindowsOnboarding.zig -target x86_64-windows-msvc ` + -lc -luser32 -lgdi32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Product Settings executable tests" { + $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent + $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") + if (-not $winghosttyRoot) { + $winghosttyRoot = Join-Path $depotRoot "Winghostty-pinned" + } + $include = Join-Path $winghosttyRoot "include" + Push-Location $shellRoot + try { + & $zig test src\WindowsProductSettings.zig -target x86_64-windows-msvc ` + -lc -luser32 -lgdi32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Frame buffer executable tests" { + Push-Location $shellRoot + try { & $zig test src\FrameBuffer.zig } finally { Pop-Location } +} +Invoke-Native "Daemon client startup 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 DaemonClient startup tests." + } + Push-Location $shellRoot + try { + & $zig test src\DaemonClient.zig -target x86_64-windows-msvc -lc -ladvapi32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Daemon supervisor handoff 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 supervisor handoff tests." + } + Push-Location $shellRoot + try { + & $zig test src\DaemonSupervisor.zig -target x86_64-windows-msvc ` + -lc -lkernel32 -ladvapi32 -lshell32 "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Workspace layout executable tests" { + Push-Location $shellRoot + try { + & $zig test src\WorkspaceLayout.zig + if ($LASTEXITCODE -ne 0) { throw "workspace layout tests failed" } + & $zig test src\InputRouter.zig + } finally { Pop-Location } +} +Invoke-Native "Terminal input queue 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 terminal input tests." + } + Push-Location $shellRoot + try { + & $zig test src\TerminalSurface.zig -target x86_64-windows-msvc -lc "-I$include" + } finally { Pop-Location } +} +Invoke-Native "Graph model executable tests" { + Push-Location $shellRoot + try { & $zig test src\GraphModel.zig } finally { Pop-Location } +} +Invoke-Native "Graph canvas 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" + } + Invoke-Native "Graph canvas input 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" + Push-Location $shellRoot + try { + & $zig test src\CanvasInput.zig -target x86_64-windows-msvc -lc "-I$include" + } finally { Pop-Location } + } + $include = Join-Path $winghosttyRoot "include" + if (-not (Test-Path -LiteralPath $include -PathType Container)) { + throw "Winghostty headers are required for graph canvas tests." + } + Push-Location $shellRoot + try { + & $zig test src\GraphCanvas.zig -target x86_64-windows-msvc -lc "-I$include" + } finally { Pop-Location } +} + +Write-Output "Windows shell scaffold contract: PASS" +exit 0 diff --git a/Tools/windows/bootstrap.ps1 b/Tools/windows/bootstrap.ps1 new file mode 100644 index 00000000..62252ce4 --- /dev/null +++ b/Tools/windows/bootstrap.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [string] $ToolRoot, + [string] $ProviderRoot, + [switch] $SkipSwift +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +if (-not $ToolRoot) { + $ToolRoot = Join-Path $repoRoot ".graphcode-tools" +} +if (-not $ProviderRoot) { + $ProviderRoot = Join-Path $ToolRoot "providers" +} +$ToolRoot = [IO.Path]::GetFullPath($ToolRoot) +$ProviderRoot = [IO.Path]::GetFullPath($ProviderRoot) +New-Item -ItemType Directory -Force $ToolRoot, $ProviderRoot | Out-Null + +function Install-Zig([string] $Version, [string] $Sha256) { + $destination = Join-Path $ToolRoot "zig-$Version" + $executable = Join-Path $destination "zig.exe" + if (Test-Path -LiteralPath $executable -PathType Leaf) { + $installedVersion = & $executable version + if ($LASTEXITCODE -eq 0 -and $installedVersion -eq $Version) { + return $executable + } + throw "Existing Zig installation is not version ${Version}: $destination" + } + + $archive = Join-Path $ToolRoot "zig-$Version.zip" + Invoke-WebRequest ` + -Uri "https://ziglang.org/download/$Version/zig-x86_64-windows-$Version.zip" ` + -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $Sha256) { + throw "Zig $Version archive checksum mismatch" + } + Expand-Archive -LiteralPath $archive -DestinationPath $ToolRoot -Force + Move-Item ` + -LiteralPath (Join-Path $ToolRoot "zig-x86_64-windows-$Version") ` + -Destination $destination + Remove-Item -LiteralPath $archive -Force + return $executable +} + +function Install-Provider([object] $Pin, [string] $Name) { + $destination = Join-Path $ProviderRoot $Name + if (-not (Test-Path -LiteralPath (Join-Path $destination ".git"))) { + git clone --no-checkout $Pin.remoteUrl $destination + if ($LASTEXITCODE -ne 0) { + throw "Cloning $Name failed" + } + } + git -C $destination fetch --quiet origin $Pin.sha + if ($LASTEXITCODE -ne 0) { + throw "Fetching $Name pin $($Pin.sha) failed" + } + git -C $destination checkout --quiet --detach $Pin.sha + if ($LASTEXITCODE -ne 0) { + throw "Checking out $Name pin $($Pin.sha) failed" + } + if (@(git -C $destination status --porcelain --untracked-files=all).Count -ne 0) { + throw "$Name provider checkout is dirty: $destination" + } + return $destination +} + +function Resolve-Swift633 { + $candidates = @( + Get-ChildItem ` + (Join-Path $env:LOCALAPPDATA "Programs\Swift\Toolchains") ` + -Recurse -Filter swift.exe -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + foreach ($candidate in $candidates) { + if ($candidate -match "\\Toolchains\\6\.3\.3[^\\]*\\usr\\bin\\swift\.exe$") { + return $candidate + } + $version = & $candidate --version 2>$null | Select-Object -First 1 + if ($LASTEXITCODE -eq 0 -and $version -match "Swift version 6\.3\.3") { + return $candidate + } + } + return $null +} + +$zig0152 = Install-Zig ` + "0.15.2" ` + "3A0ED1E8799A2F8CE2A6E6290A9FF22E6906F8227865911FB7DDEDC3CC14CB0C" +$zig0160 = Install-Zig ` + "0.16.0" ` + "68659EB5F1E4EB1437A722F1DD889C5A322C9954607F5EDCF337BC3684A75A7E" + +$pins = Get-Content ` + -LiteralPath (Join-Path $repoRoot "graphcode-windows\provider-pins.json") ` + -Raw | ConvertFrom-Json +$winghosttyRoot = Install-Provider $pins.winghostty "winghostty" +$zmxRoot = Install-Provider $pins.zmx "zmx" + +$swift = Resolve-Swift633 +if (-not $swift -and -not $SkipSwift) { + winget install --id Swift.Toolchain --exact --version 6.3.3 ` + --silent --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + throw "Installing Swift 6.3.3 failed" + } + $swift = Resolve-Swift633 +} +if (-not $swift -and -not $SkipSwift) { + throw "Swift 6.3.3 was installed but swift.exe could not be located" +} + +$values = [ordered]@{ + GRAPHCODE_ZIG0152 = $zig0152 + GRAPHCODE_ZIG0160 = $zig0160 + GRAPHCODE_WINGHOSTTY_ROOT = $winghosttyRoot + GRAPHCODE_ZMX_ROOT = $zmxRoot +} +if ($swift) { + $values["GRAPHCODE_SWIFT633"] = $swift +} + +foreach ($entry in $values.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + if ($env:GITHUB_ENV) { + "$($entry.Key)=$($entry.Value)" | Add-Content -LiteralPath $env:GITHUB_ENV + } +} + +$environmentScript = Join-Path $ToolRoot "environment.ps1" +$values.GetEnumerator() | + ForEach-Object { "`$env:$($_.Key) = '$($_.Value.Replace("'", "''"))'" } | + Set-Content -LiteralPath $environmentScript + +Write-Host "GraphCode Windows dependencies are ready." +Write-Host "Load them in a new shell with: . '$environmentScript'" +Write-Host "Validate with:" +Write-Host "pwsh -NoProfile -File Tools\windows\validate.ps1 -Task windows-shell -SwiftExecutable '$swift'" diff --git a/Tools/windows/package.ps1 b/Tools/windows/package.ps1 new file mode 100644 index 00000000..ab95b7c4 --- /dev/null +++ b/Tools/windows/package.ps1 @@ -0,0 +1,544 @@ +[CmdletBinding()] +param( + [ValidateSet("Build", "Verify", "Install", "Upgrade", "Uninstall", "CleanMachine")] + [string] $Command = "Build", + [string] $InputDirectory, + [string] $OutputDirectory, + [string] $Package, + [string] $InstallRoot = (Join-Path $env:LOCALAPPDATA "GraphCode\current"), + [string] $Version, + [string] $SignCertificate, + [string] $SignTimestampUrl, + [string] $SignToolPath, + [string] $WinghosttyRoot, + [string] $ZmxRoot, + [string] $Zig0152 = $env:GRAPHCODE_ZIG0152, + [string] $Zig0160 = $env:GRAPHCODE_ZIG0160, + [switch] $KeepUserData, + [switch] $RemoveUserData, + [switch] $NoScheduledTask, + [switch] $Force +) + +$ErrorActionPreference = "Stop" +$versionWasProvided = [bool]$Version +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$shellRoot = Join-Path $repoRoot "graphcode-windows" +$required = @("graphcoded.exe", "graphcode.exe", "zmx.exe") +$packageManifest = Get-Content (Join-Path $shellRoot "build.zig.zon") -Raw +if (-not $Version) { + if ($packageManifest -notmatch '(?m)\.version\s*=\s*"([^"]+)"') { + throw "GraphCode packaging: package version is missing" + } + $Version = $Matches[1] +} + +function Fail([string] $message) { throw "GraphCode packaging: $message" } +function Require([bool] $condition, [string] $message) { if (-not $condition) { Fail $message } } +function Resolve-Input([string] $path) { + if (-not $path) { return $null } + if (-not (Test-Path -LiteralPath $path -PathType Container)) { Fail "input directory does not exist: $path" } + return (Resolve-Path -LiteralPath $path).Path +} +function Save-Shortcut([string] $destination) { + foreach ($path in @( + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\GraphCode.lnk"), + (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\GraphCode.url") + )) { + if (Test-Path $path) { + Copy-Item $path (Join-Path $destination (Split-Path $path -Leaf)) -Force + } + } +} +function Restore-Shortcut([string] $source) { + Set-Shortcut $false + foreach ($name in @("GraphCode.lnk", "GraphCode.url")) { + $path = Join-Path $source $name + if (Test-Path $path) { + Copy-Item $path (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\$name") -Force + } + } +} +function Copy-Tree([string] $source, [string] $destination) { + New-Item -ItemType Directory -Force -Path $destination | Out-Null + Get-ChildItem -LiteralPath $source -File -Recurse | ForEach-Object { + $relative = $_.FullName.Substring($source.Length).TrimStart("\", "/") + $target = Join-Path $destination $relative + New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null + Copy-Item -LiteralPath $_.FullName -Destination $target -Force + } +} +function Get-Manifest([string] $root) { + @(Get-ChildItem -LiteralPath $root -File -Recurse | + Where-Object { + $_.FullName -ne (Join-Path $root "manifest.json") -and + $_.FullName -ne (Join-Path $root "checksums.sha256") + } | + ForEach-Object { + $relative = $_.FullName.Substring($root.Length).TrimStart("\", "/").Replace("\", "/") + [ordered]@{ + path = $relative + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + } | Sort-Object path) +} +function Normalize-ManifestPath([string] $path) { + Require (-not [string]::IsNullOrWhiteSpace($path)) "manifest contains an empty path" + $normalized = $path.Replace("\", "/") + Require (-not [IO.Path]::IsPathRooted($normalized) -and + $normalized -notmatch "^[A-Za-z]:/" -and $normalized -notmatch "://" ) ` + "manifest contains an absolute path: $path" + $parts = $normalized.Split("/") + $leaf = $parts[-1] + Require ($parts -notcontains "" -and $parts -notcontains "." -and $parts -notcontains "..") ` + "manifest contains an unsafe path: $path" + Require ($leaf -notin @("manifest.json", "checksums.sha256")) ` + "manifest contains a reserved filename: $path" + Require ($normalized -notmatch "[<>:`"|?*]") "manifest contains an invalid path: $path" + return $normalized +} +function Get-ActualPackageFiles([string] $root) { + @(Get-ChildItem -LiteralPath $root -File -Recurse | + Where-Object { + $_.FullName -ne (Join-Path $root "manifest.json") -and + $_.FullName -ne (Join-Path $root "checksums.sha256") + } | + ForEach-Object { + Normalize-ManifestPath $_.FullName.Substring($root.Length).TrimStart("\", "/") + } | Sort-Object -Unique) +} +function Write-Metadata([string] $root, [string] $version) { + $pins = Get-Content -LiteralPath (Join-Path $shellRoot "provider-pins.json") -Raw | ConvertFrom-Json + $metadata = [ordered]@{ + schemaVersion = 1 + product = "GraphCode Windows" + version = $version + platform = "windows-x86_64" + executables = [ordered]@{ shell = "bin/graphcode-windows.exe"; daemon = "bin/graphcoded.exe"; cli = "bin/graphcode.exe"; zmx = "bin/zmx.exe" } + hostAssets = @(Get-ChildItem -LiteralPath (Join-Path $root "bin") -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match "winghostty|host" } | ForEach-Object { "bin/$($_.Name)" }) + providerPins = $pins + signing = if ($SignCertificate) { "signed" } else { "UNSIGNED (development artifact; not code signed)" } + userData = "%USERPROFILE%/.graphcode (preserved by uninstall)" + providerProvenance = "provider-provenance.json" + } + $metadata | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $root "metadata.json") -Encoding utf8 + @" +GraphCode Windows distribution +Version: $version +Signing: $($metadata.signing) + +This artifact is not code signed unless an explicit signing certificate was supplied. +"@ | Set-Content -LiteralPath (Join-Path $root "SIGNING.txt") -Encoding utf8 +} +function Assert-Package([string] $root) { + Require (Test-Path -LiteralPath (Join-Path $root "metadata.json")) "metadata.json is missing" + $metadata = Get-Content (Join-Path $root "metadata.json") -Raw | ConvertFrom-Json + foreach ($name in $required) { Require (Test-Path -LiteralPath (Join-Path $root "bin\$name")) "$name is missing" } + Require (Test-Path -LiteralPath (Join-Path $root "bin\graphcode-windows.exe")) "graphcode-windows.exe is missing" + Require (@(Get-ChildItem -LiteralPath (Join-Path $root "bin") -Filter *.dll -ErrorAction SilentlyContinue).Count -gt 0) "Swift runtime DLLs are missing" + Require (Test-Path -LiteralPath (Join-Path $root "LICENSE")) "LICENSE is missing" + Require (Test-Path -LiteralPath (Join-Path $root "THIRD-PARTY-NOTICES.txt")) "third-party notices are missing" + Require (Test-Path -LiteralPath (Join-Path $root "licenses\WINGHOSTTY-LICENSE.txt")) "Winghostty license is missing" + Require (Test-Path -LiteralPath (Join-Path $root "licenses\ZMX-LICENSE.txt")) "zmx license is missing" + Require (Test-Path -LiteralPath (Join-Path $root "provider-provenance.json")) "provider provenance is missing" + Require ($metadata.platform -eq "windows-x86_64") "unsupported package platform" + return $metadata +} +function Verify-Manifest([string] $root) { + $manifestPath = Join-Path $root "manifest.json" + Require (Test-Path -LiteralPath $manifestPath) "manifest.json is missing" + $expected = Get-Content $manifestPath -Raw | ConvertFrom-Json + $entries = @($expected.files) + $normalizedEntries = @() + foreach ($entry in $entries) { + $normalized = Normalize-ManifestPath ([string] $entry.path) + Require ($entry.size -is [int] -or $entry.size -is [long] -or $entry.size -is [double]) "manifest size is invalid: $normalized" + Require ([string] $entry.sha256 -match "^[0-9a-fA-F]{64}$") "manifest hash is invalid: $normalized" + Require ($normalizedEntries -notcontains $normalized.ToLowerInvariant()) "manifest contains duplicate paths" + $normalizedEntries += $normalized.ToLowerInvariant() + $file = Join-Path $root ($normalized -replace "/", "\") + Require (Test-Path -LiteralPath $file -PathType Leaf) "manifest file is missing: $($entry.path)" + $item = Get-Item -LiteralPath $file + Require ($item.Length -eq [int64]$entry.size) "size mismatch: $normalized" + $actual = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() + Require ($actual -eq ([string]$entry.sha256).ToLowerInvariant()) "checksum mismatch: $normalized" + } + $actualFiles = @(Get-ActualPackageFiles $root | ForEach-Object { $_.ToLowerInvariant() }) + $expectedFiles = @($normalizedEntries | Sort-Object -Unique) + Require (($actualFiles -join "`n") -eq ($expectedFiles -join "`n")) "manifest file set differs from package contents" + return $expected +} +function Read-ProviderProvenance([string] $root) { + $path = Join-Path $root "provider-provenance.json" + $provenance = Get-Content $path -Raw | ConvertFrom-Json + $pins = Get-Content (Join-Path $shellRoot "provider-pins.json") -Raw | ConvertFrom-Json + foreach ($provider in @("winghostty", "zmx")) { + $p = $provenance.$provider + $pin = $pins.$provider + Require ($p.sha -eq $pin.sha -and $p.repository -eq $pin.repository) "$provider provenance pin mismatch" + Require ([string]$p.sha256 -match "^[0-9a-fA-F]{64}$") "$provider provenance digest is missing" + $normalizedPath = Normalize-ManifestPath ([string]$p.packagePath) + Require ($normalizedPath -eq $p.packagePath) "$provider provenance path is not normalized" + $artifact = Join-Path $root ($p.packagePath -replace "/", "\") + Require (Test-Path $artifact -PathType Leaf) "$provider provenance artifact is missing" + Require ((Get-FileHash $artifact -Algorithm SHA256).Hash.ToLowerInvariant() -eq $p.sha256.ToLowerInvariant()) "$provider provenance digest mismatch" + $normalizedLicensePath = Normalize-ManifestPath ([string]$p.licensePath) + Require ($normalizedLicensePath -eq $p.licensePath) "$provider license path is not normalized" + $licensePath = Join-Path $root ($normalizedLicensePath -replace "/", "\") + Require (Test-Path $licensePath -PathType Leaf) "$provider license file is missing" + Require ((Get-FileHash $licensePath -Algorithm SHA256).Hash.ToLowerInvariant() -eq $p.licenseSha256.ToLowerInvariant()) "$provider license digest mismatch" + } + return $provenance +} +function Verify-SignedPackage([string] $root, [object] $metadata) { + if ($metadata.signing -ne "signed") { return } + Require (Test-Path (Join-Path $root "SIGNATURES.txt")) "signed package has no signature record" + $tool = if ($SignToolPath) { $SignToolPath } else { (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source } + foreach ($file in @(Get-ChildItem (Join-Path $root "bin") -Filter *.exe)) { + $authenticode = Get-AuthenticodeSignature -FilePath $file.FullName + Require ($authenticode.Status -eq "Valid") "clean-machine Authenticode verification failed: $($file.Name)" + if ($tool -and (Test-Path $tool)) { + & $tool verify /pa $file.FullName *> $null + Require ($LASTEXITCODE -eq 0) "optional signtool diagnostic failed: $($file.Name)" + } + } +} +function Get-TaskIdentity([string] $support) { + $sid = ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value + $resolved = [IO.Path]::GetFullPath($support).TrimEnd("\").ToLowerInvariant() + $bytes = [Text.Encoding]::UTF8.GetBytes("$sid|$resolved") + $hash = ([Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "" + return @{ sid = $sid; name = "GraphCode\graphcoded-$($hash.Substring(0, 32))" } +} +function Xml-Escape([string] $value) { + return [System.Security.SecurityElement]::Escape($value) +} +function Build-Package { + Require ($Version -and $Version -notin @("dev", "0.0.0-dev")) "release packaging requires a non-dev package version" + $out = if ($OutputDirectory) { $OutputDirectory } else { Join-Path $repoRoot ".build\windows\packages" } + New-Item -ItemType Directory -Force -Path $out | Out-Null + $staging = Join-Path $out ".staging-$([guid]::NewGuid())" + $root = Join-Path $staging "GraphCode" + New-Item -ItemType Directory -Force -Path (Join-Path $root "bin") | Out-Null + $root = (Resolve-Path -LiteralPath $root).Path + $source = Resolve-Input $InputDirectory + if ($source) { + Copy-Tree $source (Join-Path $root "bin") + } else { + $locations = @( + (Join-Path $shellRoot "zig-out\bin"), + (Join-Path $repoRoot ".build\windows\release") + ) + foreach ($location in $locations) { if (Test-Path $location) { Get-ChildItem $location -File | Copy-Item -Destination (Join-Path $root "bin") -Force } } + } + $provenancePath = Join-Path $root "provider-provenance.json" + if ($WinghosttyRoot -or $ZmxRoot) { + Require ($WinghosttyRoot -and $ZmxRoot) "both provider roots are required" + Require ($Zig0152 -and $Zig0160) "pinned Zig 0.15.2 and 0.16.0 executables are required" + $pins = Get-Content (Join-Path $shellRoot "provider-pins.json") -Raw | ConvertFrom-Json + foreach ($spec in @( + @{ name = "winghostty"; root = $WinghosttyRoot; pin = $pins.winghostty; source = $pins.winghostty.artifact; destination = "assets/winghostty-win32-host.lib" }, + @{ name = "zmx"; root = $ZmxRoot; pin = $pins.zmx; source = $pins.zmx.artifact; destination = "bin/zmx.exe" } + )) { + Require ((git -C $spec.root rev-parse HEAD) -eq $spec.pin.sha) "$($spec.name) provider is not pinned" + Require (@(git -C $spec.root status --porcelain).Count -eq 0) "$($spec.name) provider worktree is dirty" + $zig = if ($spec.name -eq "winghostty") { $Zig0152 } else { $Zig0160 } + Require (Test-Path $zig -PathType Leaf) "pinned Zig executable is missing for $($spec.name)" + Push-Location $spec.root + try { + $buildArgs = if ($spec.name -eq "winghostty") { + @("build", "-Demit-win32-host=true") + } else { + @("build", "-Dtarget=x86_64-windows-gnu") + } + & $zig @buildArgs + Require ($LASTEXITCODE -eq 0) "$($spec.name) pinned rebuild failed" + } finally { Pop-Location } + $providerArtifact = Join-Path $spec.root ($spec.source -replace "/", "\") + Require (Test-Path $providerArtifact -PathType Leaf) "$($spec.name) provider artifact is missing" + $destination = Join-Path $root ($spec.destination -replace "/", "\") + New-Item -ItemType Directory -Force (Split-Path $destination -Parent) | Out-Null + Copy-Item $providerArtifact $destination -Force + $digest = (Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($spec.name -eq "winghostty") { $wingDigest = $digest } else { $zmxDigest = $digest } + } + @{ + schemaVersion = 1 + winghostty = @{ repository = $pins.winghostty.repository; sha = $pins.winghostty.sha; packagePath = "assets/winghostty-win32-host.lib"; sha256 = $wingDigest; trustedSha256 = $wingDigest } + zmx = @{ repository = $pins.zmx.repository; sha = $pins.zmx.sha; packagePath = "bin/zmx.exe"; sha256 = $zmxDigest; trustedSha256 = $zmxDigest } + } | ConvertTo-Json -Depth 5 | Set-Content $provenancePath -Encoding utf8 + } else { + Fail "trusted pinned Winghostty and zmx roots are required; fixture provenance is not accepted" + } + if (-not $source) { + Require ($WinghosttyRoot -and $Zig0152) "release build requires pinned Winghostty root and Zig 0.15.2" + Push-Location $shellRoot + try { + & $Zig0152 build ` + "-Dwinghostty-dir=$WinghosttyRoot" ` + "-Dwinghostty-lib=$(Join-Path $WinghosttyRoot 'zig-out\lib\winghostty-win32-host.lib')" ` + "-Dversion=$Version" ` + -Doptimize=ReleaseSafe + Require ($LASTEXITCODE -eq 0) "GraphCode Windows release build failed" + } finally { Pop-Location } + foreach ($file in @(Get-ChildItem (Join-Path $shellRoot "zig-out\bin") -File)) { + Copy-Item $file (Join-Path $root "bin\$($file.Name)") -Force + } + } + foreach ($name in $required + "graphcode-windows.exe") { + Require (Test-Path (Join-Path $root "bin\$name")) "$name was not found; pass -InputDirectory with release outputs" + } + $reportedVersion = (& (Join-Path $root "bin\graphcode-windows.exe") --version 2>$null | Select-Object -First 1).Trim() + Require ($reportedVersion -eq $Version) "graphcode-windows.exe reports $reportedVersion, expected $Version" + Require (@(Get-ChildItem (Join-Path $root "bin") -Filter *.dll).Count -gt 0) "Swift runtime DLLs were not found" + Copy-Item (Join-Path $repoRoot "LICENSE") (Join-Path $root "LICENSE") -Force + Require ($WinghosttyRoot -and $ZmxRoot) "trusted provider roots are required for license attribution" + $wingLicensePath = Join-Path $WinghosttyRoot "LICENSE" + $zmxLicensePath = Join-Path $ZmxRoot "LICENSE" + Require (Test-Path $wingLicensePath -PathType Leaf) "Winghostty LICENSE is missing" + Require (Test-Path $zmxLicensePath -PathType Leaf) "zmx LICENSE is missing" + $wingLicense = Get-Content $wingLicensePath -Raw + $zmxLicense = Get-Content $zmxLicensePath -Raw + New-Item -ItemType Directory -Force (Join-Path $root "licenses") | Out-Null + Set-Content (Join-Path $root "licenses\WINGHOSTTY-LICENSE.txt") $wingLicense -Encoding utf8 + Set-Content (Join-Path $root "licenses\ZMX-LICENSE.txt") $zmxLicense -Encoding utf8 + $provenance = Get-Content $provenancePath -Raw | ConvertFrom-Json + $provenance.winghostty | Add-Member -NotePropertyName licensePath -NotePropertyValue "licenses/WINGHOSTTY-LICENSE.txt" + $provenance.winghostty | Add-Member -NotePropertyName licenseSha256 -NotePropertyValue ` + ((Get-FileHash (Join-Path $root "licenses\WINGHOSTTY-LICENSE.txt") -Algorithm SHA256).Hash.ToLowerInvariant()) + $provenance.zmx | Add-Member -NotePropertyName licensePath -NotePropertyValue "licenses/ZMX-LICENSE.txt" + $provenance.zmx | Add-Member -NotePropertyName licenseSha256 -NotePropertyValue ` + ((Get-FileHash (Join-Path $root "licenses\ZMX-LICENSE.txt") -Algorithm SHA256).Hash.ToLowerInvariant()) + $provenance | ConvertTo-Json -Depth 8 | Set-Content $provenancePath -Encoding utf8 + @" +GraphCode provider attributions + +Winghostty: https://github.com/coneilen/winghostty +$wingLicense + +zmx: https://github.com/coneilen/zmx +$zmxLicense +"@ | Set-Content (Join-Path $root "THIRD-PARTY-NOTICES.txt") -Encoding utf8 + Copy-Item (Join-Path $shellRoot "provider-pins.json") (Join-Path $root "provider-pins.json") -Force + Write-Metadata $root $Version + if ($SignCertificate) { + $signtool = if ($SignToolPath) { (Resolve-Path $SignToolPath).Path } else { (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source } + Require $signtool "signtool.exe was not found; signed packaging requires Windows SDK" + Get-ChildItem (Join-Path $root "bin") -Filter *.exe | ForEach-Object { + $args = @("sign", "/sha1", $SignCertificate) + if ($SignTimestampUrl) { $args += @("/tr", $SignTimestampUrl, "/td", "sha256") } + $args += $_.FullName + & $signtool @args + Require ($LASTEXITCODE -eq 0) "signtool failed for $($_.Name)" + } + Set-Content (Join-Path $root "SIGNATURES.txt") -Value "Signed with certificate thumbprint $SignCertificate" -Encoding utf8 + $signedProvenance = Get-Content $provenancePath -Raw | ConvertFrom-Json + $signedProvenance.zmx.sha256 = (Get-FileHash (Join-Path $root "bin\zmx.exe") -Algorithm SHA256).Hash.ToLowerInvariant() + $signedProvenance | ConvertTo-Json -Depth 8 | Set-Content $provenancePath -Encoding utf8 + } + $manifest = [ordered]@{ schemaVersion = 1; files = @(Get-Manifest $root) } + $manifest | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $root "manifest.json") -Encoding utf8 + $lines = $manifest.files | ForEach-Object { "$($_.sha256) $($_.path)" } + $lines | Set-Content (Join-Path $root "checksums.sha256") -Encoding utf8 + $archive = Join-Path $out "GraphCode-$Version-windows-x86_64.zip" + if (Test-Path $archive) { Remove-Item $archive -Force } + Compress-Archive -Path $root -DestinationPath $archive -CompressionLevel Optimal + $final = Join-Path $out "GraphCode-$Version-windows-x86_64" + if (Test-Path $final) { Remove-Item $final -Recurse -Force } + Move-Item $root $final + Remove-Item $staging -Recurse -Force + $artifactHash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content (Join-Path $out "GraphCode-$Version-windows-x86_64.zip.sha256") "$artifactHash $(Split-Path $archive -Leaf)" -Encoding ascii + Write-Output $archive +} +function Open-Package([string] $path) { + Require (Test-Path -LiteralPath $path) "package does not exist: $path" + if ((Get-Item $path).PSIsContainer) { return (Resolve-Path $path).Path } + $extract = Join-Path ([IO.Path]::GetTempPath()) "graphcode-package-$([guid]::NewGuid())" + Expand-Archive -LiteralPath $path -DestinationPath $extract + $script:PackageExtraction = $extract + $entries = @(Get-ChildItem $extract) + $nested = @($entries | Where-Object { $_.PSIsContainer }) + if ($nested.Count -eq 1 -and $nested[0].Name -eq "GraphCode" -and + @($entries | Where-Object { -not $_.PSIsContainer }).Count -eq 0) { return $nested[0].FullName } + if ((Test-Path (Join-Path $extract "manifest.json")) -and (Test-Path (Join-Path $extract "metadata.json"))) { return $extract } + Fail "ZIP must contain one GraphCode directory or a verified flat package root" +} +function Close-Package { + if ($script:PackageExtraction) { + Remove-Item -LiteralPath $script:PackageExtraction -Recurse -Force -ErrorAction SilentlyContinue + $script:PackageExtraction = $null + } +} +function Set-UserPath([string] $bin, [bool] $add) { + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $parts = @($current -split ";" | Where-Object { $_ -and $_ -ne $bin }) + if ($add) { $parts += $bin } + [Environment]::SetEnvironmentVariable("Path", ($parts -join ";"), "User") +} +function Set-Shortcut([bool] $create) { + $shortcut = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\GraphCode.lnk" + $fallback = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\GraphCode.url" + if (-not $create) { + Remove-Item -LiteralPath $shortcut -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $fallback -Force -ErrorAction SilentlyContinue + return + } + $directory = Split-Path $shortcut -Parent + New-Item -ItemType Directory -Force -Path $directory | Out-Null + try { + $shell = New-Object -ComObject WScript.Shell + $link = $shell.CreateShortcut($shortcut) + $link.TargetPath = Join-Path $InstallRoot "bin\graphcode-windows.exe" + $link.WorkingDirectory = Join-Path $InstallRoot "bin" + $link.Description = "GraphCode Windows shell" + $link.Save() + } catch { + $target = [Uri]::new((Join-Path $InstallRoot "bin\graphcode-windows.exe")).AbsoluteUri + @" +[InternetShortcut] +URL=$target +IconFile=$(Join-Path $InstallRoot "bin\graphcode-windows.exe") +IconIndex=0 +"@ | Set-Content -LiteralPath $fallback -Encoding ascii + } +} +function Get-InstalledDaemons { + $expected = [IO.Path]::GetFullPath((Join-Path $InstallRoot "bin\graphcoded.exe")) + @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.Name -ieq "graphcoded.exe" -and $_.ExecutablePath -and + ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $expected) + }) +} +function Stop-InstalledDaemon { + $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } + $identity = Get-TaskIdentity $support + & schtasks.exe /End /TN $identity.name *> $null + $deadline = [DateTime]::UtcNow.AddSeconds(8) + while (@(Get-InstalledDaemons).Count -gt 0 -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 200 + } + foreach ($process in @(Get-InstalledDaemons)) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + $deadline = [DateTime]::UtcNow.AddSeconds(4) + while (@(Get-InstalledDaemons).Count -gt 0 -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 200 + } + Require (@(Get-InstalledDaemons).Count -eq 0) "installed graphcoded process did not stop" +} +function Remove-DaemonTask { + $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } + $identity = Get-TaskIdentity $support + & schtasks.exe /End /TN $identity.name *> $null + & schtasks.exe /Delete /TN $identity.name /F *> $null +} +function Start-DaemonTask { + $support = if ($env:GRAPHCODE_SUPPORT_DIR) { $env:GRAPHCODE_SUPPORT_DIR } else { Join-Path $env:USERPROFILE ".graphcode" } + $identity = Get-TaskIdentity $support + New-Item -ItemType Directory -Force $support | Out-Null + $xmlPath = Join-Path (Split-Path $InstallRoot -Parent) "GraphCode-daemon-task.xml" + $taskName = Xml-Escape $identity.name + $sid = Xml-Escape $identity.sid + $command = Xml-Escape (Join-Path $env:SystemRoot "System32\cmd.exe") + $arguments = Xml-Escape "/d /s /c `"set `"GRAPHCODE_SUPPORT_DIR=$support`"`&`&`"$InstallRoot\bin\graphcoded.exe`"`"" + $workingDirectory = Xml-Escape (Join-Path $InstallRoot "bin") + $xml = @" + + + GraphCode daemon for $sid + true$sid + $sidInteractiveTokenLeastPrivilege + IgnoreNewtruePT0S + $command$arguments$workingDirectory + +"@ + [IO.File]::WriteAllText($xmlPath, $xml, [Text.Encoding]::Unicode) + & schtasks.exe /Create /TN $identity.name /XML $xmlPath /F *> $null + Require ($LASTEXITCODE -eq 0) "scheduled-task registration failed" + Remove-Item $xmlPath -Force -ErrorAction SilentlyContinue + & schtasks.exe /Run /TN $identity.name *> $null + Require ($LASTEXITCODE -eq 0) "scheduled-task start failed" + $deadline = [DateTime]::UtcNow.AddSeconds(15) + while ([DateTime]::UtcNow -lt $deadline) { + if (@(Get-InstalledDaemons).Count -gt 0) { + $cli = Join-Path $InstallRoot "bin\graphcode.exe" + $env:GRAPHCODE_SUPPORT_DIR = $support + & $cli projects *> $null + if ($LASTEXITCODE -eq 0) { return } + } + Start-Sleep -Milliseconds 300 + } + throw "scheduled graphcoded endpoint did not become reachable" +} +function Install-Package([bool] $upgrade) { + try { + $root = Open-Package ($(if ($Package) { $Package } else { Fail "-Package is required" })) + $metadata = Assert-Package $root + Verify-Manifest $root | Out-Null + Read-ProviderProvenance $root | Out-Null + Verify-SignedPackage $root $metadata + if ($versionWasProvided -and $metadata.version -ne $Version) { Fail "version mismatch: expected $Version, package is $($metadata.version)" } + $parent = Split-Path $InstallRoot -Parent + New-Item -ItemType Directory -Force -Path $parent | Out-Null + $stage = Join-Path $parent ".GraphCode-install-$([guid]::NewGuid())" + Copy-Tree $root $stage + $backup = Join-Path $parent ".GraphCode-rollback-$([guid]::NewGuid())" + $shortcutBackup = Join-Path $parent ".GraphCode-shortcut-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force $shortcutBackup | Out-Null + Save-Shortcut $shortcutBackup + $oldPath = [Environment]::GetEnvironmentVariable("Path", "User") + try { + if (-not $NoScheduledTask) { Stop-InstalledDaemon; Remove-DaemonTask } + if (Test-Path $InstallRoot) { Move-Item $InstallRoot $backup } + Move-Item $stage $InstallRoot + Set-UserPath (Join-Path $InstallRoot "bin") $true + Set-Shortcut $true + if (-not $NoScheduledTask) { + Start-DaemonTask + } + } catch { + if (-not $NoScheduledTask) { + try { Stop-InstalledDaemon } catch { } + if (-not $NoScheduledTask) { Remove-DaemonTask } + } + if (Test-Path $InstallRoot) { Remove-Item $InstallRoot -Recurse -Force } + if (Test-Path $backup) { Move-Item $backup $InstallRoot } + [Environment]::SetEnvironmentVariable("Path", $oldPath, "User") + Restore-Shortcut $shortcutBackup + if (-not $NoScheduledTask -and (Test-Path (Join-Path $InstallRoot "bin\graphcoded.exe"))) { + Start-DaemonTask + } + throw + } finally { + Remove-Item $stage,$backup,$shortcutBackup -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Output "Installed GraphCode $($metadata.version) at $InstallRoot" + } finally { + Close-Package + } +} +function Uninstall-Package { + if (-not $NoScheduledTask) { Stop-InstalledDaemon; Remove-DaemonTask } + $bin = Join-Path $InstallRoot "bin" + Set-UserPath $bin $false + Set-Shortcut $false + if (Test-Path $InstallRoot) { Remove-Item $InstallRoot -Recurse -Force } + $data = Join-Path $env:USERPROFILE ".graphcode" + if ($RemoveUserData -and -not $KeepUserData) { + Remove-Item $data -Recurse -Force -ErrorAction SilentlyContinue + } else { + Write-Output "User data preserved under $data" + } +} + +switch ($Command) { + "Build" { Build-Package } + "Verify" { try { $root = Open-Package $Package; $metadata = Assert-Package $root; Verify-Manifest $root | Out-Null; Read-ProviderProvenance $root | Out-Null; Verify-SignedPackage $root $metadata; Write-Output "Package verification: PASS" } finally { Close-Package } } + "Install" { Install-Package $false } + "Upgrade" { Install-Package $true } + "Uninstall" { Uninstall-Package } + "CleanMachine" { $RemoveUserData = $true; Uninstall-Package; Remove-Item (Join-Path $env:ProgramData "GraphCode") -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/Tools/windows/terminal-gate.ps1 b/Tools/windows/terminal-gate.ps1 new file mode 100644 index 00000000..215be31f --- /dev/null +++ b/Tools/windows/terminal-gate.ps1 @@ -0,0 +1,348 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $WinghosttyRoot, + [Parameter(Mandatory)] + [string] $ZmxRoot, + [string] $Zig0152 = "zig", + [string] $Zig0160 = "zig", + [switch] $SkipBuild, + [switch] $Stress +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$gateRoot = Join-Path $repoRoot "investigation\spikes\windows-terminal-gate" +$pins = Get-Content (Join-Path $gateRoot "provider-pins.json") -Raw | ConvertFrom-Json +$app = Join-Path $gateRoot "zig-out\bin\graphcode-terminal-gate.exe" +$wingLib = Join-Path $WinghosttyRoot "zig-out\lib\winghostty-win32-host.lib" +$zmx = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" +$ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() +$ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() +$gateOutputFiles = [System.Collections.Generic.List[string]]::new() +$gateProcess = $null +$resourceRole = "winghostty" +$metricSequence = 0 +$sessionPrefix = "gc-$([guid]::NewGuid().ToString('N'))" +$names = @( + "$sessionPrefix-a", + "$sessionPrefix-b", + "$sessionPrefix-shared" +) + +function Invoke-Native([string] $description, [scriptblock] $command) { + Write-Host "==> $description" + & $command + if ($LASTEXITCODE -ne 0) { + throw "$description failed with exit code $LASTEXITCODE" + } +} + +function Assert-Equal([string] $actual, [string] $expected, [string] $label) { + if ($actual -ne $expected) { + throw "$label expected $expected but found $actual" + } +} + +function Assert-HistoryContains([string] $name, [string] $marker, [string] $label) { + for ($attempt = 0; $attempt -lt 40; $attempt++) { + $history = (& $zmx history $name --vt 2>&1 | Out-String) + if ($LASTEXITCODE -eq 0 -and + $history -match [regex]::Escape($marker)) { + return + } + Start-Sleep -Milliseconds 250 + } + throw "$label did not contain the persistent VT marker '$marker'" +} + +function Assert-ZmxSessionHealthy([string] $name, [string] $label) { + for ($attempt = 0; $attempt -lt 40; $attempt++) { + & $zmx get $name *> $null + if ($LASTEXITCODE -eq 0) { + return + } + Start-Sleep -Milliseconds 250 + } + throw "$label did not become reachable" +} + +function Assert-PinnedCleanWorktree( + [string] $root, + [string] $expectedSha, + [string] $label +) { + if (-not (Test-Path -LiteralPath (Join-Path $root ".git"))) { + throw "$label provider root is not a Git worktree: $root" + } + $status = @(git -C $root status --porcelain --untracked-files=all) + if ($LASTEXITCODE -ne 0) { + throw "$label provider status failed" + } + if ($status.Count -ne 0) { + throw "$label provider worktree is dirty; use a clean pinned worktree or immutable artifact" + } + Assert-Equal (git -C $root rev-parse HEAD) $expectedSha "$label pin" +} + +function Record-TestOwnedSessions { + foreach ($name in $names) { + [void] $ownedSessionNames.Add($name) + foreach ($processId in @(Get-ZmxSessionProcessIds $name)) { + [void] $ownedProcessIds.Add($processId) + } + } +} + +function Write-OwnedResourceMetrics([string] $phase) { + $script:metricSequence++ + $metrics = @($ownedProcessIds | ForEach-Object { + $p = Get-Process -Id $_ -ErrorAction SilentlyContinue + if ($p) { + [pscustomobject]@{ + pid = $_ + role = if ($p.ProcessName -match "zmx") { "zmx" } elseif ($_.Equals($script:gateProcess.Id)) { $resourceRole } else { $p.ProcessName } + handles = [int64]$p.HandleCount + privateBytes = [int64]$p.PrivateMemorySize64 + } + + } + }) + Write-Host ("PRODUCT_RESOURCE_METRICS_JSON=" + (@{ + snapshotId = "$sessionPrefix-$resourceRole-$script:metricSequence" + phase = $phase + sessions = @($ownedSessionNames) + processes = $metrics + } | ConvertTo-Json -Compress -Depth 5)) +} + +function Invoke-GateProcess([string[]] $arguments, [string] $phase) { + $outputPrefix = Join-Path ([IO.Path]::GetTempPath()) ` + "graphcode-terminal-gate-$([guid]::NewGuid().ToString('N'))" + $stdoutPath = "$outputPrefix.stdout.log" + $stderrPath = "$outputPrefix.stderr.log" + [void] $gateOutputFiles.Add($stdoutPath) + [void] $gateOutputFiles.Add($stderrPath) + $script:gateProcess = Start-Process -FilePath $app ` + -ArgumentList $arguments ` + -NoNewWindow ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + [void] $ownedProcessIds.Add($script:gateProcess.Id) + Start-Sleep -Milliseconds 250 + Record-TestOwnedSessions + Write-OwnedResourceMetrics $phase + $script:gateProcess.WaitForExit() + $exitCode = $script:gateProcess.ExitCode + $script:gateProcess.Dispose() + $script:gateProcess = $null + if ($exitCode -ne 0) { + $stdout = Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue + $stderr = Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue + throw "terminal gate exited with code ${exitCode}: stdout=$stdout, stderr=$stderr" + } +} + +function Get-ZmxSessionProcessIds([string] $name) { + $ids = [System.Collections.Generic.List[int]]::new() + $escaped = [regex]::Escape($name) + foreach ($process in @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match "(?i)^zmx(?:\.exe)?$" -and + $_.CommandLine -and + $_.CommandLine -match $escaped + } + )) { + $ids.Add([int] $process.ProcessId) + } + return @($ids | Select-Object -Unique) +} + +function Assert-ZmxSessionAbsent([string] $name) { + for ($attempt = 0; $attempt -lt 20; $attempt++) { + $processIds = @(Get-ZmxSessionProcessIds $name) + if ($processIds.Count -eq 0) { + return + } + + Start-Sleep -Milliseconds 250 + } + $details = @($processIds | ForEach-Object { "pid=$_" }) -join "; " + throw "cleanup left zmx session '$name' running: $details" +} + +function Get-ProcessTreeIds([int[]] $roots) { + $all = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $ids = [Collections.Generic.HashSet[int]]::new() + foreach ($root in $roots) { [void] $ids.Add($root) } + $changed = $true + while ($changed) { + $changed = $false + foreach ($process in $all) { + if (-not $ids.Contains([int]$process.ProcessId) -and + $ids.Contains([int]$process.ParentProcessId)) { + [void] $ids.Add([int]$process.ProcessId) + $changed = $true + } + } + } + return @($ids) +} + +Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" +Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" + +try { + if (-not $SkipBuild) { + Invoke-Native "Winghostty host artifact" { + Push-Location $WinghosttyRoot + try { & $Zig0152 build -Demit-win32-host=true } finally { Pop-Location } + } + Invoke-Native "zmx Windows provider artifact" { + Push-Location $ZmxRoot + try { & $Zig0160 build -Dtarget=x86_64-windows-gnu } finally { Pop-Location } + } + Invoke-Native "GraphCode terminal gate" { + Push-Location $gateRoot + try { + & $Zig0152 build ` + "-Dwinghostty-dir=$WinghosttyRoot" ` + "-Dwinghostty-lib=$wingLib" ` + -Doptimize=ReleaseSafe + } finally { Pop-Location } + } + } + + if (-not (Test-Path $app)) { throw "terminal gate executable is missing: $app" } + if (-not (Test-Path $zmx)) { throw "zmx executable is missing: $zmx" } + + $env:GRAPHCODE_ZMX = $zmx + $env:GRAPHCODE_GATE_CWD = $repoRoot + $env:GRAPHCODE_TERMINAL_SESSION_PREFIX = $sessionPrefix + Write-Host "terminal gate session prefix: $sessionPrefix" + try { + Invoke-Native "terminal gate first attach smoke" { + Invoke-GateProcess @("--smoke") "terminal-gate:typed-input" + } + Record-TestOwnedSessions + Write-OwnedResourceMetrics "terminal-gate:typed-input" + Invoke-Native "first-session health" { + Assert-ZmxSessionHealthy $names[0] "session A" + Assert-ZmxSessionHealthy $names[1] "session B" + } + Invoke-Native "session shell pwd/cwd" { + & $zmx send $names[0] "cd`r" + & $zmx send $names[1] "cd`r" + } + $expectedCwd = ([System.IO.Path]::GetFullPath($repoRoot)).TrimEnd("\") + Assert-HistoryContains $names[0] ` + $expectedCwd "session A cwd" + Assert-HistoryContains $names[1] ` + $expectedCwd "session B cwd" + Assert-HistoryContains $names[0] ` + "GraphCode typed output A" "typed A output" + Assert-HistoryContains $names[1] ` + "GraphCode typed output B" "typed B output" + Invoke-Native "seed persistent shell output" { + & $zmx send $names[0] "echo GraphCode persistent VT output A`r" + & $zmx send $names[1] "echo GraphCode persistent VT output B`r" + } + Assert-HistoryContains $names[0] ` + "GraphCode persistent VT output A" "first-session A history" + Assert-HistoryContains $names[1] ` + "GraphCode persistent VT output B" "first-session B history" + Invoke-Native "terminal gate independent restart attach smoke" { + Invoke-GateProcess @("--smoke") "terminal-gate:reconnect" + } + Record-TestOwnedSessions + Invoke-Native "restart-session health" { + Assert-ZmxSessionHealthy $names[0] "restart session A" + Assert-ZmxSessionHealthy $names[1] "restart session B" + } + Assert-HistoryContains $names[0] ` + "GraphCode persistent VT output A" "restart A history" + Assert-HistoryContains $names[1] ` + "GraphCode persistent VT output B" "restart B history" + Assert-HistoryContains $names[0] ` + "GraphCode typed output A" "restart typed A history" + Assert-HistoryContains $names[1] ` + "GraphCode typed output B" "restart typed B history" + Invoke-Native "terminal gate same-session attach smoke" { + Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:typed-input" + } + Record-TestOwnedSessions + Invoke-Native "same-session health" { + Assert-ZmxSessionHealthy $names[2] "shared session" + } + Invoke-Native "seed shared persistent shell output" { + & $zmx send $names[2] "echo GraphCode shared VT output`r" + } + Assert-HistoryContains $names[2] ` + "GraphCode shared VT output" "same-session history" + Invoke-Native "terminal gate same-session restart smoke" { + Invoke-GateProcess @("--smoke", "--same-session") "terminal-gate:reconnect" + } + Record-TestOwnedSessions + Assert-HistoryContains $names[2] ` + "GraphCode shared VT output" "same-session restart history" + if ($Stress) { + Invoke-Native "terminal gate destroy/recreate stress" { + Invoke-GateProcess @("--smoke", "--stress") "terminal-gate:stress" + } + Record-TestOwnedSessions + Invoke-Native "post-stress session health" { + Assert-ZmxSessionHealthy $names[0] "post-stress session A" + Assert-ZmxSessionHealthy $names[1] "post-stress session B" + } + } + Write-Host "Windows terminal gate smoke/stress: PASS" + } + finally { + $cleanupFailures = [System.Collections.Generic.List[string]]::new() + foreach ($name in $names) { + foreach ($processId in @(Get-ZmxSessionProcessIds $name)) { + [void] $ownedSessionNames.Add($name) + [void] $ownedProcessIds.Add($processId) + } + } + $treeProcessIds = Get-ProcessTreeIds @($ownedProcessIds) + foreach ($processId in $treeProcessIds) { + [void] $ownedProcessIds.Add($processId) + } + foreach ($name in @($ownedSessionNames)) { + if (@(Get-ZmxSessionProcessIds $name).Count -ne 0) { + & $zmx kill $name *> $null + } + } + foreach ($processId in @($ownedProcessIds)) { + if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + } + foreach ($name in @($ownedSessionNames)) { + try { + Assert-ZmxSessionAbsent $name + } catch { + $cleanupFailures.Add($_.Exception.Message) + } + } + if ($env:GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE -eq "1") { + $cleanupFailures.Add("injected cleanup failure") + } + if ($cleanupFailures.Count -ne 0) { + throw "terminal gate cleanup failed: $($cleanupFailures -join '; ')" + } + } +} +finally { + foreach ($path in $gateOutputFiles) { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue + Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue + Remove-Item Env:GRAPHCODE_TERMINAL_SESSION_PREFIX -ErrorAction SilentlyContinue +} + +exit 0 diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 new file mode 100644 index 00000000..8a7d0aa2 --- /dev/null +++ b/Tools/windows/uia-live-gate.ps1 @@ -0,0 +1,1753 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Shell, + [string] $Zmx = "", + [string[]] $ArgumentList = @() +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Automation; +public static class GraphCodeUiaGateState { + public static volatile bool LiveObserved; + public static volatile bool NamePropertyObserved; + public static volatile bool FocusObserved; + public static int LiveEvents; + public static int NamePropertyEvents; + public static int SelectedEvents; + public static int AddedEvents; + public static int RemovedEvents; + public static int TogglePropertyEvents; + public static string LiveSourceAutomationId; + public static string LiveSourceName; + public static string LiveSourceRuntimeId; + public static string NamePropertySourceAutomationId; + public static string FocusSourceAutomationId; + public static string SelectionSourceAutomationId; + public static string TogglePropertySourceAutomationId; + public static readonly AutomationEventHandler LiveHandler = HandleLive; + public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; + public static readonly AutomationFocusChangedEventHandler FocusHandler = HandleFocus; + public static readonly AutomationEventHandler SelectedHandler = HandleSelected; + public static readonly AutomationEventHandler AddedHandler = HandleAdded; + public static readonly AutomationEventHandler RemovedHandler = HandleRemoved; + public static readonly AutomationPropertyChangedEventHandler TogglePropertyHandler = HandleToggleProperty; + private static void HandleLive(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element == null) return; + LiveSourceAutomationId = element.Current.AutomationId; + LiveSourceName = element.Current.Name; + LiveSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + LiveEvents++; + LiveObserved = true; + } + private static void HandleNameProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) NamePropertySourceAutomationId = element.Current.AutomationId; + NamePropertyEvents++; + NamePropertyObserved = true; + } + private static void HandleFocus(object sender, AutomationFocusChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) FocusSourceAutomationId = element.Current.AutomationId; + FocusObserved = true; + } + private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + SelectedEvents++; + } + private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + AddedEvents++; + } + private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + RemovedEvents++; + } + private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { + var element = sender as AutomationElement; + if (element != null) TogglePropertySourceAutomationId = element.Current.AutomationId; + TogglePropertyEvents++; + } + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr window, uint message, UIntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "SendMessageW")] + private static extern IntPtr SendMessageText( + IntPtr window, uint message, UIntPtr wParam, StringBuilder lParam + ); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr childAfter, string className, string windowName); + private delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool SetWindowText(IntPtr window, string text); + [DllImport("user32.dll")] + private static extern int GetDlgCtrlID(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr SetFocus(IntPtr window); + [DllImport("user32.dll")] + private static extern IntPtr GetFocus(); + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr window); + public static IntPtr FindChild(IntPtr parent, string className) { + return FindWindowEx(parent, IntPtr.Zero, className, null); + } + public static IntPtr FindTopLevel(string className, uint processId) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId) return true; + var actualClass = new StringBuilder(256); + GetClassName(window, actualClass, actualClass.Capacity); + if (!String.Equals(actualClass.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static string[] GetListItems(IntPtr list) { + if (list == IntPtr.Zero) return new string[0]; + int count = (int)SendMessage(list, 0x018B, UIntPtr.Zero, IntPtr.Zero); + var result = new string[count]; + for (int index = 0; index < count; index++) { + int length = (int)SendMessage(list, 0x018A, (UIntPtr)index, IntPtr.Zero); + var text = new StringBuilder(length + 1); + SendMessageText(list, 0x0189, (UIntPtr)index, text); + result[index] = text.ToString(); + } + return result; + } + public static bool PostFixtureMutation(IntPtr window, uint mutation) { + return PostMessage(window, 0x802A, (UIntPtr)mutation, IntPtr.Zero); + } + public static bool PostPaletteRefresh(IntPtr window) { + return PostMessage(window, 0x802B, UIntPtr.Zero, IntPtr.Zero); + } + public static bool PostTaggedExitCollision(IntPtr window) { + return PostMessage(window, 0x0111, new UIntPtr(0x8000000000001008UL), IntPtr.Zero); + } + public static bool PostKeyboard(IntPtr window, uint key) { + return PostMessage(window, 0x0100, (UIntPtr)key, IntPtr.Zero); + } + public static bool SendReturn(IntPtr window) { + if (window == IntPtr.Zero) return false; + SendMessage(window, 0x0100, (UIntPtr)0x0D, IntPtr.Zero); + return true; + } + public static bool SendCommand(IntPtr window, uint command) { + if (window == IntPtr.Zero) return false; + SendMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); + return true; + } + public static int GetCheckState(IntPtr window) { + if (window == IntPtr.Zero) return -1; + return (int)SendMessage(window, 0x00F0, UIntPtr.Zero, IntPtr.Zero); + } + public static bool FocusControl(IntPtr parent, IntPtr control) { + if (parent == IntPtr.Zero || control == IntPtr.Zero) return false; + SetForegroundWindow(parent); + SetFocus(control); + return GetFocus() == control; + } + public static bool ActivateWindow(IntPtr window) { + return window != IntPtr.Zero && SetForegroundWindow(window); + } + public static bool PostMouseClick(IntPtr window) { + return PostMessage(window, 0x0201, UIntPtr.Zero, IntPtr.Zero); + } + public static bool PostCommand(IntPtr window, uint command) { + return PostMessage(window, 0x0111, (UIntPtr)command, IntPtr.Zero); + } + public static bool PostClose(IntPtr window) { + return PostMessage(window, 0x0010, UIntPtr.Zero, IntPtr.Zero); + } + public static bool SetFirstEditText(IntPtr parent, string text) { + var edit = FindWindowEx(parent, IntPtr.Zero, "Edit", null); + if (edit == IntPtr.Zero || !SetWindowText(edit, text)) return false; + ulong command = ((ulong)0x0300 << 16) | (uint)GetDlgCtrlID(edit); + SendMessage(parent, 0x0111, (UIntPtr)command, edit); + return true; + } +} +"@ -ReferencedAssemblies @( + [System.Windows.Automation.AutomationElement].Assembly.Location, + [System.Windows.Automation.AutomationEventArgs].Assembly.Location +) + +function Require([bool] $condition, [string] $message) { + if (-not $condition) { throw $message } +} + +function Get-DirectChildren( + [System.Windows.Automation.AutomationElement] $element, + [System.Windows.Automation.TreeWalker] $walker +) { + $children = New-Object System.Collections.Generic.List[System.Windows.Automation.AutomationElement] + $child = $walker.GetFirstChild($element) + while ($null -ne $child) { + $children.Add($child) + $child = $walker.GetNextSibling($child) + } + return @($children.ToArray()) +} + +function Assert-Ids([string[]] $actual, [string[]] $expected, [string] $label) { + Require (@($actual).Count -eq @($expected).Count) "$label count expected $($expected.Count) but found $($actual.Count): $($actual -join ',')" + Require ((@($actual) -join "|") -eq (@($expected) -join "|")) "$label expected $($expected -join ',') but found $($actual -join ',')" +} + +function Get-RuntimeIdentity([System.Windows.Automation.AutomationElement] $element) { + return (@($element.GetRuntimeId()) -join ",") +} + +function Find-FragmentById( + [System.Windows.Automation.AutomationElement] $root, + [string] $automationId, + [System.Windows.Automation.TreeWalker] $walker +) { + $pending = New-Object System.Collections.Generic.Queue[System.Windows.Automation.AutomationElement] + $pending.Enqueue($root) + while ($pending.Count -gt 0) { + $current = $pending.Dequeue() + foreach ($child in @(Get-DirectChildren $current $walker)) { + if ($child.Current.AutomationId -eq $automationId) { return $child } + $pending.Enqueue($child) + } + } + return $null +} + +function Assert-FragmentLinks( + [System.Windows.Automation.AutomationElement] $parent, + [System.Windows.Automation.TreeWalker] $walker, + [string[]] $expectedIds, + [string] $label +) { + $allChildren = @(Get-DirectChildren $parent $walker) + $allowedNativeIds = if ($label -match "root$") { @("4601", "4602") } else { @() } + $unexpectedIds = @($allChildren | ForEach-Object { $_.Current.AutomationId } | + Where-Object { $_ -and $_ -notin $expectedIds -and $_ -notin $allowedNativeIds }) + Require ($unexpectedIds.Count -eq 0) "$label exposed unexpected children: $($unexpectedIds -join ',')" + $children = @($allChildren | Where-Object { $_.Current.AutomationId -in $expectedIds }) + $ids = @($children | ForEach-Object { $_.Current.AutomationId }) + Assert-Ids $ids $expectedIds "$label direct children" + for ($i = 0; $i -lt $children.Count; $i++) { + $child = $children[$i] + Require ($walker.GetParent($child).Current.AutomationId -eq $parent.Current.AutomationId) "$label parent mismatch for $($ids[$i])" + $previous = $walker.GetPreviousSibling($child) + $next = $walker.GetNextSibling($child) + while ($null -ne $previous -and $previous.Current.AutomationId -notin $expectedIds) { + $previous = $walker.GetPreviousSibling($previous) + } + while ($null -ne $next -and $next.Current.AutomationId -notin $expectedIds) { + $next = $walker.GetNextSibling($next) + } + if ($i -eq 0) { + Require ($null -eq $previous) "$label first child has a previous sibling" + } else { + Require ($previous.Current.AutomationId -eq $ids[$i - 1]) "$label previous sibling mismatch for $($ids[$i])" + } + if ($i -eq $children.Count - 1) { + Require ($null -eq $next) "$label last child has a next sibling" + } else { + Require ($next.Current.AutomationId -eq $ids[$i + 1]) "$label next sibling mismatch for $($ids[$i])" + } + } + return $children +} + +$oldZmx = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX") +$oldCwd = [Environment]::GetEnvironmentVariable("GRAPHCODE_GATE_CWD") +$oldGate = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_GATE") +$oldConnectionFailure = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_CONNECTION_FAILURE") +$oldUser = [Environment]::GetEnvironmentVariable("USERNAME") +$oldFixture = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_FIXTURE_ROWS") +$oldDaemonPipe = [Environment]::GetEnvironmentVariable("GRAPHCODE_DAEMON_PIPE") +$oldSupportDirectory = [Environment]::GetEnvironmentVariable("GRAPHCODE_SUPPORT_DIR") +$oldResetSidebar = [Environment]::GetEnvironmentVariable("GRAPHCODE_UIA_RESET_SIDEBAR") +$process = $null +$settingsProcess = $null +$status = $null +$liveRegionEvent = $null +$eventHandler = $null +$propertyHandler = $null +$focusHandler = $null +$liveEventRegistered = $false +$propertyEventRegistered = $false +$focusEventRegistered = $false +$stressJob = $null +$policyDirectory = $null +$policyPath = $null +$policyDirectoryExisted = $false +$policyExisted = $false +$policyContents = $null +$settingsDirectory = $null +$settingsPath = $null +$settingsErrorPath = $null +try { + if ($Zmx) { $env:GRAPHCODE_ZMX = $Zmx } + $env:GRAPHCODE_GATE_CWD = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path + $env:GRAPHCODE_UIA_GATE = "1" + $env:GRAPHCODE_UIA_CONNECTION_FAILURE = "1" + $env:USERNAME = "GraphCodeUIAGate" + $env:GRAPHCODE_UIA_FIXTURE_ROWS = "C:\fixture-safe|safe,C:\fixture-unsafe|unsafe" + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\graphcode-uia-gate-$PID" + $settingsDirectory = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode-uia-product-settings-$PID" + $settingsPath = Join-Path $settingsDirectory "settings.json" + $settingsErrorPath = Join-Path $settingsDirectory "stderr.log" + New-Item -ItemType Directory -Path $settingsDirectory -Force | Out-Null + [IO.File]::WriteAllText( + $settingsPath, + '{"defaultBackend":"claudeCode","defaultModelTier":"capable",' + + '"claudePermissionMode":"auto","copilotPermissions":"allowEverything",' + + '"codexApprovals":"workspace","showsActivityStrip":true,' + + '"briefsSessionsAboutTheGraph":false,"betaUpdates":true,' + + '"autoSelectsModel":true,"gateSentinel":"preserve"}' + ) + $env:GRAPHCODE_SUPPORT_DIR = $settingsDirectory + $env:GRAPHCODE_UIA_RESET_SIDEBAR = "1" + $policyDirectory = Join-Path $env:GRAPHCODE_GATE_CWD ".graphcode" + $policyPath = Join-Path $policyDirectory "worktree-policy.json" + $policyDirectoryExisted = Test-Path -LiteralPath $policyDirectory + $policyExisted = Test-Path -LiteralPath $policyPath + if ($policyExisted) { $policyContents = [IO.File]::ReadAllBytes($policyPath) } + if ($ArgumentList.Count -gt 0) { + $process = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru -WindowStyle Normal + } else { + $process = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal + } + + $root = $null + for ($i = 0; $i -lt 160; $i++) { + Start-Sleep -Milliseconds 250 + $process.Refresh() + if ($process.HasExited) { throw "shell exited with code $($process.ExitCode)" } + if ($process.MainWindowHandle -ne 0) { + $candidate = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle) + if ($candidate.Current.AutomationId -eq "graphcode-root") { + $root = $candidate + break + } + } + } + if ($null -eq $root) { throw "shell did not expose graphcode-root through WM_GETOBJECT" } + + $expectedRootIds = @("projects", "loops", "worktrees", "graph", "actions", "status") + $rawWalker = [System.Windows.Automation.TreeWalker]::RawViewWalker + $controlWalker = [System.Windows.Automation.TreeWalker]::ControlViewWalker + $shellWindow = $process.MainWindowHandle + $desktop = [System.Windows.Automation.AutomationElement]::RootElement + $status = Find-FragmentById $root "status" $controlWalker + $rawRootChildren = @(Assert-FragmentLinks $root $rawWalker $expectedRootIds "RawView root") + $controlRootChildren = @(Assert-FragmentLinks $root $controlWalker $expectedRootIds "ControlView root") + + $projects = Find-FragmentById $root "projects" $rawWalker + $loops = Find-FragmentById $root "loops" $rawWalker + $graph = Find-FragmentById $root "graph" $rawWalker + Require (($null -ne $projects) -and ($null -ne $loops) -and ($null -ne $graph)) "missing Projects, Loops, or Graph fragments" + $connectionAlert = $root.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, + "GraphCode daemon unavailable. Navigation remains available while reconnection continues." + )) + ) + Require ($null -ne $connectionAlert) "connection failure did not expose its inline canvas banner" + Require (($connectionAlert.Current.BoundingRectangle.Width -gt 0) -and + ($connectionAlert.Current.BoundingRectangle.Height -gt 0)) ` + "connection failure banner had empty bounds" + $navigationIds = @("overview-destination", "quick-chats-destination") + $canvasActionIds = @("canvas-primary-action", "zoom-out", "actual-size", "zoom-in", "fit-canvas") + $projectRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { $_.Current.AutomationId -match '^project-row-' }) + $graphDestination = Find-FragmentById $root "overview-destination" $rawWalker + Require (($null -ne $graphDestination) -and ($graphDestination.Current.Name -eq "Graph")) ` + "global sidebar destination did not expose the pinned Graph identity" + Require ($projectRows.Count -eq 3) "Projects did not expose grouped recent rows and the open project row" + Require ((@($projectRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "Fixture local|Fixture remote|UIA project") "dynamic project row names were not synchronized" + foreach ($projectRow in $projectRows) { + Require (($projectRow.Current.BoundingRectangle.Width -gt 0) -and + ($projectRow.Current.BoundingRectangle.Height -gt 0)) "dynamic project row has empty bounds" + } + $null = $projects.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $null = $projectRows[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $projectRowInvoke = $projectRows[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $projectRowIds = @($projectRows | ForEach-Object { $_.Current.AutomationId }) + $projectChildIds = @(Get-DirectChildren $projects $rawWalker | + ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) + $null = Assert-FragmentLinks $projects $rawWalker $projectChildIds "RawView Projects" + $null = Assert-FragmentLinks $projects $controlWalker $projectChildIds "ControlView Projects" + $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" + }) | Select-Object -First 1 + $remoteSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Remote Repositories" + }) | Select-Object -First 1 + Require (($null -ne $localSection) -and ($null -ne $remoteSection)) ` + "Local and Remote sidebar sections did not expose independent actions" + $remoteRowId = @($projectRows | Where-Object { $_.Current.Name -eq "Fixture remote" })[0].Current.AutomationId + $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $collapsedProjectNames = @(Get-DirectChildren $projects $rawWalker | + Where-Object { $_.Current.AutomationId -match '^project-row-' } | + ForEach-Object { $_.Current.Name }) + Require (("Fixture local" -notin $collapsedProjectNames) -and + ("Fixture remote" -in $collapsedProjectNames)) ` + "Local section collapse affected the Remote section or retained its Local child" + $remoteAfterLocalCollapse = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -eq $remoteRowId + }) | Select-Object -First 1 + Require ($null -ne $remoteAfterLocalCollapse) "Remote row identity changed during Local collapse" + $localSection = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^sidebar-section-' -and $_.Current.Name -eq "Local Projects" + }) | Select-Object -First 1 + $localSection.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + + $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) + Require (($loopRows.Count -eq 1) -and ($loopRows[0].Current.Name -eq "UIA loop A")) ` + "nested loop tree did not start collapsed at its root" + $loopDisclosure = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-disclosure-' -and + $_.Current.Name -eq "Expand loop children" + }) | Select-Object -First 1 + Require ($null -ne $loopDisclosure) "nested root omitted its disclosure action" + $rootLoopId = $loopRows[0].Current.AutomationId + $loopDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) + Require (($loopRows.Count -eq 2) -and + ((@($loopRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and + ($loopRows[0].Current.AutomationId -eq $rootLoopId)) ` + "nested disclosure did not reveal its child while preserving root identity" + $projectDisclosure = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^project-disclosure-' -and + $_.Current.Name -eq "Collapse project" + }) | Select-Object -First 1 + Require ($null -ne $projectDisclosure) "open project omitted its disclosure action" + $projectDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + Require (@(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }).Count -eq 0) "project disclosure did not collapse its loop tree" + $projectDisclosure = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^project-disclosure-' -and + $_.Current.Name -eq "Expand project" + }) | Select-Object -First 1 + Require ($null -ne $projectDisclosure) "project disclosure did not expose collapsed state" + $projectDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $loopRows = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) + Require (($loopRows.Count -eq 2) -and + ($loopRows[0].Current.AutomationId -eq $rootLoopId)) ` + "project expansion did not restore its stable nested loop tree" + $projectNewLoop = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^project-new-loop-' -and $_.Current.Name -eq "New Loop" + }) | Select-Object -First 1 + Require ($null -ne $projectNewLoop) "project row omitted New Loop" + $projectNewLoop.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $sidebarNodeForm = $null + $sidebarNodeFormCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit node" + )) + ) + for ($index = 0; $index -lt 40 -and $null -eq $sidebarNodeForm; $index++) { + Start-Sleep -Milliseconds 50 + $sidebarNodeForm = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, $sidebarNodeFormCondition + ) + } + Require ($null -ne $sidebarNodeForm) "project-row New Loop did not open the node form" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$sidebarNodeForm.Current.NativeWindowHandle + )) "project-row New Loop form rejected cancellation" + Start-Sleep -Milliseconds 150 + $loopIds = @($loopRows | ForEach-Object { $_.Current.AutomationId }) + $null = $loops.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + foreach ($row in $loopRows) { + $null = $row.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $null = $row.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + $loopChildIds = @(Get-DirectChildren $loops $rawWalker | + ForEach-Object { $_.Current.AutomationId } | Where-Object { $_ }) + $null = Assert-FragmentLinks $loops $rawWalker $loopChildIds "RawView Loops" + $null = Assert-FragmentLinks $loops $controlWalker $loopChildIds "ControlView Loops" + $projectCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and + $_.Current.Name -in @("UIA loop A", "UIA loop B") + }) + Require (($projectCards.Count -eq 2) -and + ((@($projectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Graph did not expose synchronized project cards" + foreach ($card in $projectCards) { + Require (($card.Current.BoundingRectangle.Width -gt 0) -and + ($card.Current.BoundingRectangle.Height -gt 0)) "dynamic project card has empty bounds" + $null = $card.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $null = $card.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + } + $projectCardIds = @($projectCards | ForEach-Object { $_.Current.AutomationId }) + $graphChildIds = @($projectCardIds + @($connectionAlert.Current.AutomationId) + $canvasActionIds) + $null = Assert-FragmentLinks $graph $rawWalker $graphChildIds "RawView Graph" + $null = Assert-FragmentLinks $graph $controlWalker $graphChildIds "ControlView Graph" + $projectCards[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $compositeChildren = @(Get-DirectChildren $graph $rawWalker | Where-Object { $_.Current.AutomationId -match '^canvas-card-' }) + $nestedCards = @($compositeChildren | Where-Object { $_.Current.Name -match '^UIA nested ' }) + Require (($nestedCards.Count -eq 2) -and + ((@($nestedCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA nested A|UIA nested B")) ` + "Open Group did not expose the nested composite canvas" + $compositeBack = @($compositeChildren | Where-Object { $_.Current.Name -eq "Back to UIA project" }) + Require ($compositeBack.Count -eq 1) ` + "Composite canvas did not expose its Back breadcrumb: $(@($compositeChildren | ForEach-Object { $_.Current.Name }) -join '|')" + Require (($compositeBack[0].Current.BoundingRectangle.Width -gt 0) -and + ($compositeBack[0].Current.BoundingRectangle.Height -gt 0)) "Composite Back breadcrumb has empty bounds" + $compositeBack[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $restoredProjectCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require (($restoredProjectCards.Count -eq 2) -and + ((@($restoredProjectCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` + "Composite Back did not restore the parent project canvas" + $surfaceActionPatterns = @{} + foreach ($id in @($navigationIds + $canvasActionIds)) { + $element = Find-FragmentById $root $id $rawWalker + Require ($null -ne $element) "missing $id fragment" + Require (($element.Current.BoundingRectangle.Width -gt 0) -and + ($element.Current.BoundingRectangle.Height -gt 0)) "$id has empty bounds" + $surfaceActionPatterns[$id] = $element.GetCurrentPattern( + [System.Windows.Automation.InvokePattern]::Pattern) + } + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 150 + $overviewCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require (($overviewCards.Count -eq 2) -and + ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) "Overview did not expose synchronized cards" + $surfaceActionPatterns["quick-chats-destination"].Invoke() + Start-Sleep -Milliseconds 150 + $quickChatCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA chat ' + }) + Require (($quickChatCards.Count -eq 2) -and + ((@($quickChatCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA chat A|UIA chat B")) "Quick Chats did not expose synchronized cards: $(@($quickChatCards | ForEach-Object { $_.Current.Name }) -join '|')" + $quickChatRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-row-' + }) + Require (($quickChatRows.Count -eq 2) -and + ((@($quickChatRows | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA chat A|UIA chat B")) ` + "Quick Chats sidebar children were not exposed as stable rows" + foreach ($chatRow in $quickChatRows) { + $null = $chatRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + $quickDisclosure = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chats-disclosure-' + }) | Select-Object -First 1 + Require (($null -ne $quickDisclosure) -and + ($quickDisclosure.Current.Name -eq "Collapse Quick Chats")) ` + "Quick Chats disclosure did not expose its expanded state" + $firstQuickRowId = $quickChatRows[0].Current.AutomationId + $quickDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + Require (@(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-row-' + }).Count -eq 0) "Quick Chats disclosure did not collapse child rows" + $quickDisclosure = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chats-disclosure-' + }) | Select-Object -First 1 + Require ($quickDisclosure.Current.Name -eq "Expand Quick Chats") ` + "Quick Chats disclosure state did not update after collapse" + $quickDisclosure.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + $restoredQuickRows = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-row-' + }) + Require (($restoredQuickRows.Count -eq 2) -and + ($restoredQuickRows[0].Current.AutomationId -eq $firstQuickRowId)) ` + "Quick Chats expansion did not preserve stable child identity" + $newChatAction = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^quick-chat-new-' -and $_.Current.Name -eq "New Chat" + }) | Select-Object -First 1 + Require ($null -ne $newChatAction) "Quick Chats header omitted New Chat" + $newChatAction.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Creating quick chat...") ` + "Quick Chats New Chat action did not execute" + $quickChatCardIds = @($quickChatCards | ForEach-Object { $_.Current.AutomationId }) + $quickChatCards[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 150 + Require ((Find-FragmentById $root "status" $rawWalker).Current.Name -eq "Opening quick chat...") ` + "Quick Chat invocation did not perform its expected action" + $surfaceActionPatterns["zoom-in"].Invoke() + $surfaceActionPatterns["actual-size"].Invoke() + $surfaceActionPatterns["zoom-out"].Invoke() + $surfaceActionPatterns["fit-canvas"].Invoke() + Start-Sleep -Milliseconds 250 + $process.Refresh() + Require (-not $process.HasExited) "surface UIA actions terminated the shell" + $activeProjectRow = @(Get-DirectChildren $projects $rawWalker | Where-Object { + $_.Current.AutomationId -match '^project-row-' -and $_.Current.Name -eq "UIA project" + }) | Select-Object -First 1 + $activeLoopRow = @(Get-DirectChildren $loops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' -and $_.Current.Name -eq "UIA loop A" + }) | Select-Object -First 1 + Require (($null -ne $activeProjectRow) -and ($null -ne $activeLoopRow)) ` + "sidebar rows were unavailable before dynamic invocation" + $activeProjectRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $activeLoopRow.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + Start-Sleep -Milliseconds 250 + $process.Refresh() + Require (-not $process.HasExited) "dynamic project or loop invocation terminated the shell" + $workspaceCards = @(Get-DirectChildren $graph $rawWalker | Where-Object { + $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' + }) + Require (($workspaceCards.Count -eq 2) -and + ((@($workspaceCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B") -and + $workspaceCards[0].GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Current.IsSelected) ` + "loop invocation did not transition to the selected workspace loop" + $surfaceActionPatterns["overview-destination"].Invoke() + Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::PostTaggedExitCollision($process.MainWindowHandle)) ` + "tagged command collision message was rejected" + Start-Sleep -Milliseconds 150 + $process.Refresh() + Require (-not $process.HasExited) "tagged UIA payload fell through to the Exit menu command" + + $worktrees = Find-FragmentById $root "worktrees" $rawWalker + Require ($null -ne $worktrees) "missing Worktrees fragment" + $initialRowIds = @() + for ($attempt = 0; $attempt -lt 50; $attempt++) { + $initialRowIds = @(Get-DirectChildren $worktrees $rawWalker | + ForEach-Object { $_.Current.AutomationId } | + Where-Object { $_ }) + if ($initialRowIds.Count -eq 2) { break } + Start-Sleep -Milliseconds 100 + } + Require (($initialRowIds.Count -eq 2) -and + ($initialRowIds | ForEach-Object { $_ -match '^worktree-row-[0-9]+$' } | Where-Object { -not $_ }).Count -eq 0) ` + "Worktrees did not expose two stable dynamic row IDs: $($initialRowIds -join ','); status=$((Find-FragmentById $root 'status' $rawWalker).Current.Name)" + $rawRows = @(Assert-FragmentLinks $worktrees $rawWalker $initialRowIds "RawView Worktrees") + $controlRows = @(Assert-FragmentLinks $worktrees $controlWalker $initialRowIds "ControlView Worktrees") + Require ((@($rawRows | ForEach-Object { $_.Current.Name }) -join "|") -eq + "C:\fixture-safe|C:\fixture-unsafe") "fixture worktree names were not ordered as expected" + + $selection = $worktrees.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $safeRow = @($rawRows | Where-Object { $_.Current.Name -eq "C:\fixture-safe" })[0] + $unsafeRow = @($rawRows | Where-Object { $_.Current.Name -eq "C:\fixture-unsafe" })[0] + $safeFocusRow = @($controlRows | Where-Object { $_.Current.Name -eq "C:\fixture-safe" })[0] + Require (($null -ne $safeRow) -and ($null -ne $unsafeRow) -and ($null -ne $safeFocusRow)) "missing fixture worktree rows" + $safeRowId = $safeRow.Current.AutomationId + $safeRowRuntimeId = Get-RuntimeIdentity $safeRow + $safeSelection = $safeRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + $unsafeSelection = $unsafeRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + [GraphCodeUiaGateState]::SelectedEvents = 0 + [GraphCodeUiaGateState]::AddedEvents = 0 + [GraphCodeUiaGateState]::RemovedEvents = 0 + [GraphCodeUiaGateState]::SelectionSourceAutomationId = $null + $selectedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementSelectedEvent + $addedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementAddedToSelectionEvent + $removedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementRemovedFromSelectionEvent + $selectedHandler = [GraphCodeUiaGateState]::SelectedHandler + $addedHandler = [GraphCodeUiaGateState]::AddedHandler + $removedHandler = [GraphCodeUiaGateState]::RemovedHandler + [System.Windows.Automation.Automation]::AddAutomationEventHandler( + $selectedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $selectedHandler + ) + [System.Windows.Automation.Automation]::AddAutomationEventHandler( + $addedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $addedHandler + ) + [System.Windows.Automation.Automation]::AddAutomationEventHandler( + $removedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $removedHandler + ) + try { + $safeSelection.Select() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::SelectedEvents -lt 1; $index++) { + Start-Sleep -Milliseconds 50 + } + Require (($safeSelection.Current.IsSelected) -and + ([GraphCodeUiaGateState]::SelectedEvents -eq 1)) "Select did not raise ElementSelected exactly once" + $safeSelection.Select() + Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::SelectedEvents -eq 1) "idempotent Select raised a duplicate event" + $unsafeRejected = $false + try { $unsafeSelection.Select() } catch { $unsafeRejected = $true } + Require $unsafeRejected "unsafe SelectionItem.Select was accepted" + $safeSelection.RemoveFromSelection() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::RemovedEvents -lt 1; $index++) { + Start-Sleep -Milliseconds 50 + } + Require (($selection.Current.GetSelection().Count -eq 0) -and + ([GraphCodeUiaGateState]::RemovedEvents -eq 1)) "RemoveFromSelection did not raise ElementRemovedFromSelection" + $safeSelection.RemoveFromSelection() + Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::RemovedEvents -eq 1) "idempotent RemoveFromSelection raised a duplicate event" + $safeSelection.AddToSelection() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::AddedEvents -lt 1; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::AddedEvents -eq 1) "AddToSelection did not raise ElementAddedToSelection" + $safeSelection.AddToSelection() + Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::AddedEvents -eq 1) "idempotent AddToSelection raised a duplicate event" + $safeSelection.RemoveFromSelection() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::RemovedEvents -lt 2; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::RemovedEvents -eq 2) "second RemoveFromSelection did not raise an event" + Require ([GraphCodeUiaGateState]::PostKeyboard($process.MainWindowHandle, 0x28)) "keyboard selection message was rejected" + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::SelectedEvents -lt 2; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::SelectedEvents -eq 2) "App keyboard selection did not raise ElementSelected" + Require ([GraphCodeUiaGateState]::PostMouseClick($process.MainWindowHandle)) "mouse selection message was rejected" + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::RemovedEvents -lt 3; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::RemovedEvents -eq 3) "App mouse selection did not raise ElementRemovedFromSelection" + Require ([GraphCodeUiaGateState]::SelectionSourceAutomationId -eq $safeRowId) "selection event source identity changed" + } finally { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($selectedEvent, $safeRow, $selectedHandler) + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($addedEvent, $safeRow, $addedHandler) + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($removedEvent, $safeRow, $removedHandler) + } + $selectionEventEvidence = @{ + selected = [GraphCodeUiaGateState]::SelectedEvents + added = [GraphCodeUiaGateState]::AddedEvents + removed = [GraphCodeUiaGateState]::RemovedEvents + source = [GraphCodeUiaGateState]::SelectionSourceAutomationId + } + + $actions = @{} + foreach ($actionId in @("inspect-worktrees", "reclaim-worktrees", "reveal-worktree", + "edit-worktree-policy", "save-worktree-policy", + "allow-reclaim", "confirm-each-reclaim")) { + $action = Find-FragmentById $root $actionId $rawWalker + Require ($null -ne $action) "missing action $actionId" + $actions[$actionId] = $action.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + $allowReclaim = Find-FragmentById $root "allow-reclaim" $controlWalker + $confirmReclaim = Find-FragmentById $root "confirm-each-reclaim" $controlWalker + Require (($null -ne $allowReclaim) -and ($null -ne $confirmReclaim)) "missing worktree policy toggles" + $allowToggle = $allowReclaim.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern) + $confirmToggle = $confirmReclaim.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern) + [GraphCodeUiaGateState]::TogglePropertyEvents = 0 + [GraphCodeUiaGateState]::TogglePropertySourceAutomationId = $null + $togglePropertyHandler = [GraphCodeUiaGateState]::TogglePropertyHandler + [System.Windows.Automation.Automation]::AddAutomationPropertyChangedEventHandler( + $allowReclaim, [System.Windows.Automation.TreeScope]::Element, $togglePropertyHandler, + [System.Windows.Automation.TogglePattern]::ToggleStateProperty + ) + [System.Windows.Automation.Automation]::AddAutomationPropertyChangedEventHandler( + $confirmReclaim, [System.Windows.Automation.TreeScope]::Element, $togglePropertyHandler, + [System.Windows.Automation.TogglePattern]::ToggleStateProperty + ) + try { + $allowToggle.Toggle() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::TogglePropertyEvents -lt 1; $index++) { + Start-Sleep -Milliseconds 50 + } + Require (([GraphCodeUiaGateState]::TogglePropertyEvents -eq 1) -and + ([GraphCodeUiaGateState]::TogglePropertySourceAutomationId -eq "allow-reclaim")) "allow reclaim did not raise ToggleState property change" + $confirmToggle.Toggle() + for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::TogglePropertyEvents -lt 2; $index++) { + Start-Sleep -Milliseconds 50 + } + Require (([GraphCodeUiaGateState]::TogglePropertyEvents -eq 2) -and + ([GraphCodeUiaGateState]::TogglePropertySourceAutomationId -eq "confirm-each-reclaim")) "confirm reclaim did not raise ToggleState property change" + } finally { + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( + $allowReclaim, $togglePropertyHandler + ) + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( + $confirmReclaim, $togglePropertyHandler + ) + } + + $status = Find-FragmentById $root "status" $controlWalker + Require ($null -ne $status) "missing status element" + $initialStatus = $status.Current.Name + $statusRuntimeId = Get-RuntimeIdentity $status + [GraphCodeUiaGateState]::LiveObserved = $false + [GraphCodeUiaGateState]::NamePropertyObserved = $false + [GraphCodeUiaGateState]::LiveEvents = 0 + [GraphCodeUiaGateState]::NamePropertyEvents = 0 + [GraphCodeUiaGateState]::LiveSourceAutomationId = $null + [GraphCodeUiaGateState]::LiveSourceName = $null + [GraphCodeUiaGateState]::LiveSourceRuntimeId = $null + [GraphCodeUiaGateState]::NamePropertySourceAutomationId = $null + [GraphCodeUiaGateState]::FocusObserved = $false + [GraphCodeUiaGateState]::FocusSourceAutomationId = $null + $eventHandler = [GraphCodeUiaGateState]::LiveHandler + $liveRegionEvent = [System.Windows.Automation.AutomationEvent]::LookupById(20024) + $propertyHandler = [GraphCodeUiaGateState]::NamePropertyHandler + $focusHandler = [GraphCodeUiaGateState]::FocusHandler + [System.Windows.Automation.Automation]::AddAutomationEventHandler( + $liveRegionEvent, $status, [System.Windows.Automation.TreeScope]::Element, $eventHandler + ) + $liveEventRegistered = $true + [System.Windows.Automation.Automation]::AddAutomationPropertyChangedEventHandler( + $status, [System.Windows.Automation.TreeScope]::Element, $propertyHandler, + [System.Windows.Automation.AutomationElement]::NameProperty + ) + $propertyEventRegistered = $true + [System.Windows.Automation.Automation]::AddAutomationFocusChangedEventHandler($focusHandler) + $focusEventRegistered = $true + try { + Require ([GraphCodeUiaGateState]::PostKeyboard($process.MainWindowHandle, 0x28)) "negative keyboard selection message was rejected" + Start-Sleep -Milliseconds 150 + Require ([GraphCodeUiaGateState]::PostMouseClick($process.MainWindowHandle)) "negative mouse selection message was rejected" + Start-Sleep -Milliseconds 150 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 1)) "negative fixture reorder message was rejected" + Start-Sleep -Milliseconds 150 + Assert-Ids @((Get-DirectChildren $worktrees $rawWalker | ForEach-Object { $_.Current.AutomationId })) ` + @($initialRowIds[1], $initialRowIds[0]) "negative reordered Worktrees" + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 1)) "fixture reorder restore message was rejected" + Start-Sleep -Milliseconds 150 + Assert-Ids @((Get-DirectChildren $worktrees $rawWalker | ForEach-Object { $_.Current.AutomationId })) ` + $initialRowIds "restored Worktrees" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 3)) "eligibility mutation message was rejected" + Start-Sleep -Milliseconds 150 + $unsafeSelection.Select() + Start-Sleep -Milliseconds 150 + Require $unsafeSelection.Current.IsSelected "eligibility-only sync did not make the fixture row selectable" + + $allowStateBefore = $allowToggle.Current.ToggleState + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 4)) "allow policy sync message was rejected" + for ($index = 0; $index -lt 20 -and $allowToggle.Current.ToggleState -eq $allowStateBefore; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ($allowToggle.Current.ToggleState -ne $allowStateBefore) "allow policy-only sync was not observed" + $confirmStateBefore = $confirmToggle.Current.ToggleState + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 5)) "confirm policy sync message was rejected" + for ($index = 0; $index -lt 20 -and $confirmToggle.Current.ToggleState -eq $confirmStateBefore; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ($confirmToggle.Current.ToggleState -ne $confirmStateBefore) "confirm policy-only sync was not observed" + + Start-Sleep -Milliseconds 250 + $statusNoChangeLiveEvents = [GraphCodeUiaGateState]::LiveEvents + $statusNoChangeNameEvents = [GraphCodeUiaGateState]::NamePropertyEvents + Require ($status.Current.Name -eq $initialStatus) "non-status sync changed status text from '$initialStatus' to '$($status.Current.Name)'" + Require ($statusNoChangeLiveEvents -eq 0) "non-status sync raised LiveRegionChanged" + Require ($statusNoChangeNameEvents -eq 0) "non-status sync raised a status Name property change" + + $actions["save-worktree-policy"].Invoke() + for ($i = 0; $i -lt 40 -and [GraphCodeUiaGateState]::LiveEvents -lt 1; $i++) { + Start-Sleep -Milliseconds 50 + } + } finally { + if ($propertyEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( + $status, $propertyHandler + ) + $propertyEventRegistered = $false + } + if ($liveEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler( + $liveRegionEvent, $status, $eventHandler + ) + $liveEventRegistered = $false + } + } + + $statusAfter = Find-FragmentById $root "status" $controlWalker + $statusTextAfter = [string]$statusAfter.Current.Name + $statusEventObserved = [GraphCodeUiaGateState]::LiveObserved + Require $statusEventObserved "LiveRegionChanged was not delivered for status" + Require ([GraphCodeUiaGateState]::LiveEvents -eq 1) "status change did not raise exactly one LiveRegionChanged event" + Require ([GraphCodeUiaGateState]::LiveSourceAutomationId -eq "status") "LiveRegionChanged source was not status" + Require ([GraphCodeUiaGateState]::LiveSourceRuntimeId -eq $statusRuntimeId) "LiveRegionChanged source identity changed" + Require ([GraphCodeUiaGateState]::NamePropertyObserved) "status Name property change was not delivered" + Require ([GraphCodeUiaGateState]::NamePropertyEvents -eq 1) "status change did not raise exactly one Name property change" + Require ([GraphCodeUiaGateState]::NamePropertySourceAutomationId -eq "status") "status Name property source was not status" + Require (($statusTextAfter -ne $initialStatus) -and + ([GraphCodeUiaGateState]::LiveSourceName -eq $statusTextAfter)) "status LiveRegionChanged did not expose updated text" + + $currentRowsBeforeFocus = @(Get-DirectChildren $worktrees $rawWalker) + $currentSafe = @($currentRowsBeforeFocus | Where-Object { $_.Current.Name -eq "C:\fixture-safe" })[0] + Require ($null -ne $currentSafe) "safe worktree row disappeared before focus: $(@($currentRowsBeforeFocus | ForEach-Object { $_.Current.AutomationId }) -join ',')" + Require ($currentSafe.Current.AutomationId -eq $safeRowId) "safe worktree identity changed before focus: $safeRowId -> $($currentSafe.Current.AutomationId)" + Require ($safeFocusRow.Current.Name -eq "C:\fixture-safe") "safe worktree provider became unavailable before focus" + $focused = $null + for ($index = 0; $index -lt 20; $index++) { + $null = [GraphCodeUiaGateState]::ActivateWindow($shellWindow) + $safeFocusRow.SetFocus() + Start-Sleep -Milliseconds 50 + $candidate = [System.Windows.Automation.AutomationElement]::FocusedElement + if ($candidate.Current.AutomationId -eq $safeRowId) { + $focused = $candidate + break + } + } + Require ($null -ne $focused) "worktree row could not retain focus against concurrent desktop focus changes; focused=$($candidate.Current.AutomationId):$($candidate.Current.Name)" + Require ($focused.Current.AutomationId -eq $safeRowId) "focus source identity was '$($focused.Current.AutomationId)', expected '$safeRowId'" + Require ((Get-RuntimeIdentity $focused) -eq (Get-RuntimeIdentity $safeFocusRow)) "focus runtime identity changed" + for ($index = 0; $index -lt 20 -and -not [GraphCodeUiaGateState]::FocusObserved; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::FocusObserved) "FocusChanged was not delivered" + Require ([GraphCodeUiaGateState]::FocusSourceAutomationId -eq $safeRowId) "FocusChanged source identity changed" + $initialFocusSource = [GraphCodeUiaGateState]::FocusSourceAutomationId + + $stressJob = Start-Job -ArgumentList ([int64]$process.MainWindowHandle) -ScriptBlock { + param([int64] $window) + $ErrorActionPreference = "Stop" + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $element = [System.Windows.Automation.AutomationElement]::FromHandle([IntPtr]$window) + for ($index = 0; $index -lt 100; $index++) { + $null = $element.Current.Name + Start-Sleep -Milliseconds 5 + } + } + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 1)) "fixture reorder message was rejected" + Start-Sleep -Milliseconds 150 + $reorderedRows = @(Get-DirectChildren $worktrees $rawWalker) + $reorderedRowIds = @($reorderedRows | ForEach-Object { $_.Current.AutomationId }) + Assert-Ids $reorderedRowIds @($initialRowIds[1], $initialRowIds[0]) "reordered Worktrees" + $reorderedSafe = Find-FragmentById $root $safeRowId $rawWalker + Require (($null -ne $reorderedSafe) -and + ((Get-RuntimeIdentity $reorderedSafe) -eq $safeRowRuntimeId)) "reordered safe row lost its identity" + Require ($rawWalker.GetParent($reorderedSafe).Current.AutomationId -eq "worktrees") "reordered safe row lost its parent" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($process.MainWindowHandle, 2)) "fixture removal message was rejected" + Start-Sleep -Milliseconds 150 + $null = Wait-Job -Job $stressJob -Timeout 10 + $stressErrors = @() + Receive-Job -Job $stressJob -ErrorAction SilentlyContinue -ErrorVariable +stressErrors | Out-Null + Require ($stressJob.State -eq "Completed") "UIA read stress did not finish: $($stressJob.State) $($stressErrors -join '; ')" + Remove-Job -Job $stressJob -Force + $remainingRows = @(Get-DirectChildren $worktrees $rawWalker) + $remainingRowIds = @($remainingRows | ForEach-Object { $_.Current.AutomationId }) + Assert-Ids $remainingRowIds @($unsafeRow.Current.AutomationId) "removed Worktrees" + $removedProviderUnavailable = $false + try { + $null = $safeRow.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::NameProperty) + } catch [System.Windows.Automation.ElementNotAvailableException] { + $removedProviderUnavailable = $true + } + Require $removedProviderUnavailable "removed worktree provider remained available" + for ($index = 0; $index -lt 20 -and + [GraphCodeUiaGateState]::FocusSourceAutomationId -ne "graphcode-root"; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ([GraphCodeUiaGateState]::FocusSourceAutomationId -eq "graphcode-root") "removed focus did not fall back to the root" + [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) + $focusEventRegistered = $false + + $rootName = $root.Current.Name + $rootAutomationId = $root.Current.AutomationId + $rootControlType = $root.Current.ControlType.ProgrammaticName + $rawRootChildIds = @($rawRootChildren | ForEach-Object { $_.Current.AutomationId }) + $controlRootChildIds = @($controlRootChildren | ForEach-Object { $_.Current.AutomationId }) + $rawWorktreeRowIds = $initialRowIds + $controlWorktreeRowIds = $initialRowIds + $focusIdentity = $safeRowId + + + $shellWindow = $process.MainWindowHandle + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 7)) "Rename Loop fixture command was rejected" + $renameDialog = $null + $desktop = [System.Windows.Automation.AutomationElement]::RootElement + $renameWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Rename Loop" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $renameCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $renameWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $renameDialog; $index++) { + Start-Sleep -Milliseconds 50 + $renameDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $renameCondition + ) + } + Require ($null -ne $renameDialog) "Rename Loop command did not open its native dialog" + $renameElements = @($renameDialog.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + )) + $renameContent = @($renameElements | ForEach-Object { $_.Current.Name }) -join "`n" + Require ($renameContent -match "Choose the title shown") "Rename Loop dialog omitted its explanation" + Require ($renameContent -match "(?m)^Title$") "Rename Loop dialog omitted its Title field label" + Require ($renameContent -match "UIA loop A") "Rename Loop dialog did not populate the current title" + Require ([GraphCodeUiaGateState]::SetFirstEditText( + [IntPtr]$renameDialog.Current.NativeWindowHandle, "UIA renamed loop" + )) "Rename Loop dialog omitted its native editable title field" + Require ([GraphCodeUiaGateState]::SendReturn( + [IntPtr]$renameDialog.Current.NativeWindowHandle + )) "Rename Loop dialog rejected Return" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingRename = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $renameCondition + ) + if ($null -eq $remainingRename) { break } + } + Require ($null -eq $remainingRename) "Return did not submit and close the Rename Loop dialog" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 14)) ` + "jump palette fixture command was rejected" + $jumpPalette = $null + $jumpPaletteCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Jump to loop" + )) + ) + for ($index = 0; $index -lt 40 -and $null -eq $jumpPalette; $index++) { + Start-Sleep -Milliseconds 50 + $jumpPalette = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $jumpPaletteCondition + ) + } + Require ($null -ne $jumpPalette) "jump command did not open the native palette" + $jumpSearch = $jumpPalette.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, "Edit" + )) + ) + $jumpSearchLabel = $jumpPalette.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Search loops" + )) + ) + Require (($null -ne $jumpSearch) -and ($null -ne $jumpSearchLabel)) ` + "jump palette did not expose its visible search field" + Require (($jumpSearch.Current.BoundingRectangle.Width -gt 0) -and + ($jumpSearch.Current.BoundingRectangle.Height -gt 0)) ` + "jump palette search field had empty bounds" + $jumpList = $jumpPalette.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, "ListBox" + )) + ) + Require (($null -ne $jumpList) -and + ($jumpList.Current.BoundingRectangle.Width -gt 0) -and + ($jumpList.Current.BoundingRectangle.Height -gt 0)) ` + "jump palette did not expose its visible ranked result list" + $jumpListHandle = [GraphCodeUiaGateState]::FindChild( + [IntPtr]$jumpPalette.Current.NativeWindowHandle, "ListBox" + ) + $jumpNames = @([GraphCodeUiaGateState]::GetListItems($jumpListHandle)) + Require ($jumpNames.Count -ge 2) "jump palette did not expose at least two ranked results" + Require (($jumpNames -match 'UIA loop A.*UIA project.*Goal.*succeeded').Count -ge 1) ` + "jump palette omitted project, loop type, or state context for UIA loop A" + Require (($jumpNames -match 'UIA loop C.*Jump fixture.*Timed.*awaitingInput').Count -ge 1) ` + "jump palette did not expose a contextual cross-project result" + $jumpWindow = [IntPtr]$jumpPalette.Current.NativeWindowHandle + Require ([GraphCodeUiaGateState]::SetFirstEditText($jumpWindow, "UIA loop B")) ` + "jump palette rejected live query input" + Require ([GraphCodeUiaGateState]::PostPaletteRefresh($jumpWindow)) ` + "jump palette rejected deterministic live-filter refresh" + Start-Sleep -Milliseconds 100 + $filteredJumpNames = @([GraphCodeUiaGateState]::GetListItems($jumpListHandle)) + Require (($filteredJumpNames.Count -eq 1) -and + ($filteredJumpNames[0] -match 'UIA loop B.*UIA project.*Proactive.*running')) ` + "jump palette did not live-filter to the ranked keyboard destination ($($filteredJumpNames -join '|'))" + Require ([GraphCodeUiaGateState]::PostKeyboard($jumpWindow, 0x0D)) ` + "jump palette rejected Return activation" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingJump = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $jumpPaletteCondition + ) + if ($null -eq $remainingJump) { break } + } + Require ($null -eq $remainingJump) "Return did not activate and close the jump palette" + $loopsAfterJump = Find-FragmentById $root "loops" $rawWalker + $loopBAfterJump = @(Get-DirectChildren $loopsAfterJump $rawWalker | + Where-Object { $_.Current.Name -eq "UIA loop B" }) | Select-Object -First 1 + Require ($null -ne $loopBAfterJump) "jump navigation did not retain the destination loop row" + $selectedAfterJump = $loopBAfterJump.GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).Current.IsSelected + Require $selectedAfterJump "jump palette activation did not navigate to UIA loop B" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 8)) "inline ingress-error fixture command was rejected" + $inlineError = $null + $inlineErrorCondition = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Folder could not be opened" + ) + for ($index = 0; $index -lt 40 -and $null -eq $inlineError; $index++) { + Start-Sleep -Milliseconds 50 + $inlineError = $root.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $inlineErrorCondition + ) + } + Require ($null -ne $inlineError) "canvas did not expose the scoped ingress error" + Require (($inlineError.Current.BoundingRectangle.Width -gt 0) -and + ($inlineError.Current.BoundingRectangle.Height -gt 0)) ` + "inline ingress error had empty canvas bounds" + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 9)) "empty overview fixture command was rejected" + Start-Sleep -Milliseconds 200 + $openFolderButton = Find-FragmentById $root "4601" $rawWalker + $emptyOverviewLoopButton = Find-FragmentById $root "4602" $rawWalker + Require (($null -ne $openFolderButton) -and ($openFolderButton.Current.Name -eq "Open Folder...")) ` + "empty global graph omitted its Open Folder action" + Require (($null -ne $emptyOverviewLoopButton) -and ($emptyOverviewLoopButton.Current.Name -eq "New Loop")) ` + "empty global graph omitted its New Loop action" + Require ((-not $openFolderButton.Current.IsOffscreen) -and + (-not $emptyOverviewLoopButton.Current.IsOffscreen)) ` + "empty global graph actions were not visible" + Require ([GraphCodeUiaGateState]::PostCommand($shellWindow, 4601)) ` + "empty global Open Folder command was rejected" + $folderPicker = $null + $folderPickerWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, + "Open GraphCode folder or Git repository" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $folderPickerCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $folderPickerWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $folderPicker; $index++) { + Start-Sleep -Milliseconds 50 + $folderPicker = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $folderPickerCondition + ) + } + Require ($null -ne $folderPicker) "Open Folder did not launch the native folder picker" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$folderPicker.Current.NativeWindowHandle + )) "native folder picker rejected cancellation" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostCommand($shellWindow, 4602)) ` + "empty global New Loop command was rejected" + $nodeForm = $null + $nodeFormWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Create or edit node" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $nodeFormCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $nodeFormWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $nodeForm; $index++) { + Start-Sleep -Milliseconds 50 + $nodeForm = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $nodeFormCondition + ) + } + Require ($null -ne $nodeForm) "empty global New Loop did not open the node form" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$nodeForm.Current.NativeWindowHandle + )) "empty global node form rejected cancellation" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 10)) "empty project fixture command was rejected" + Start-Sleep -Milliseconds 200 + $emptyProjectLoopButton = Find-FragmentById $root "4602" $rawWalker + Require (($null -ne $emptyProjectLoopButton) -and + ($emptyProjectLoopButton.Current.Name -eq "New Loop") -and + (-not $emptyProjectLoopButton.Current.IsOffscreen)) ` + "empty project canvas omitted its visible New Loop action" + Require ([GraphCodeUiaGateState]::PostCommand($shellWindow, 4602)) ` + "empty project New Loop command was rejected" + $projectNodeForm = $null + for ($index = 0; $index -lt 40 -and $null -eq $projectNodeForm; $index++) { + Start-Sleep -Milliseconds 50 + $projectNodeForm = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $nodeFormCondition + ) + } + Require ($null -ne $projectNodeForm) "empty project New Loop did not open the node form" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$projectNodeForm.Current.NativeWindowHandle + )) "empty project node form rejected cancellation" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 11)) ` + "Remote Connection fixture command was rejected" + $remoteDialog = $null + $remoteWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Remote Connection" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $remoteCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $remoteWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $remoteDialog; $index++) { + Start-Sleep -Milliseconds 50 + $remoteDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $remoteCondition + ) + } + Require ($null -ne $remoteDialog) "Remote Connection action did not open its read-only sheet" + $remoteContent = @($remoteDialog.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) | ForEach-Object { $_.Current.Name }) -join "`n" + Require ($remoteContent -match "ssh://builder/GraphCode") ` + "Remote Connection sheet omitted the encoded project identity" + Require ($remoteContent -match "removing and adding the remote project") ` + "Remote Connection sheet omitted its management guidance" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$remoteDialog.Current.NativeWindowHandle + )) "Remote Connection sheet rejected close" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 12)) ` + "Delete All Loops fixture command was rejected" + $deleteLoopsDialog = $null + $deleteLoopsWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Delete All Loops" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $deleteLoopsCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $deleteLoopsWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $deleteLoopsDialog; $index++) { + Start-Sleep -Milliseconds 50 + $deleteLoopsDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $deleteLoopsCondition + ) + } + Require ($null -ne $deleteLoopsDialog) "Delete All Loops did not open its confirmation" + $deleteLoopsContent = @($deleteLoopsDialog.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) | ForEach-Object { $_.Current.Name }) -join "`n" + Require ($deleteLoopsContent -match "every loop and graph connection") ` + "Delete All Loops confirmation omitted graph consequences" + Require ($deleteLoopsContent -match "project files remain on disk") ` + "Delete All Loops confirmation omitted filesystem consequences" + Require ([GraphCodeUiaGateState]::SendCommand( + [IntPtr]$deleteLoopsDialog.Current.NativeWindowHandle, 7 + )) "Delete All Loops confirmation rejected its safe No action" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 13)) ` + "Delete Edge fixture command was rejected" + $deleteEdgeDialog = $null + $deleteEdgeWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Delete Edge" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $deleteEdgeCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $deleteEdgeWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $deleteEdgeDialog; $index++) { + Start-Sleep -Milliseconds 50 + $deleteEdgeDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $deleteEdgeCondition + ) + } + Require ($null -ne $deleteEdgeDialog) "Delete Edge did not open its confirmation" + $deleteEdgeContent = @($deleteEdgeDialog.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + ) | ForEach-Object { $_.Current.Name }) -join "`n" + Require (($deleteEdgeContent -match "Planner") -and ($deleteEdgeContent -match "Builder")) ` + "Delete Edge confirmation omitted its endpoint loop names" + Require ($deleteEdgeContent -match "handoff graph connection") ` + "Delete Edge confirmation omitted the connection kind and graph consequence" + Require ($deleteEdgeContent -match "loops themselves remain") ` + "Delete Edge confirmation omitted the retained-loop consequence" + Require ([GraphCodeUiaGateState]::SendCommand( + [IntPtr]$deleteEdgeDialog.Current.NativeWindowHandle, 7 + )) "Delete Edge confirmation rejected its safe No action" + Start-Sleep -Milliseconds 200 + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($shellWindow, 6)) "About dialog fixture command was rejected" + $aboutDialog = $null + $aboutWindowCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "About GraphCode" + )), + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + )) + ) + $aboutCondition = New-Object System.Windows.Automation.AndCondition( + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + )), + $aboutWindowCondition + ) + for ($index = 0; $index -lt 40 -and $null -eq $aboutDialog; $index++) { + Start-Sleep -Milliseconds 50 + $aboutDialog = $desktop.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + $aboutCondition + ) + } + if ($null -eq $aboutDialog) { + $processWindowCondition = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $process.Id + ) + $windowNames = @($desktop.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + $processWindowCondition + ) | ForEach-Object { $_.Current.Name }) + throw "About command did not open the native About GraphCode dialog; process windows: $($windowNames -join ', '); status: $($status.Current.Name)" + } + $aboutElements = @($aboutDialog.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + )) + $aboutContent = @($aboutElements | ForEach-Object { $_.Current.Name }) -join "`n" + $aboutDescendants = @($aboutElements | ForEach-Object { "$($_.Current.ControlType.ProgrammaticName):$($_.Current.Name)" }) + Require ($aboutContent -match "GraphCode\s+for Windows") "About dialog omitted the product identity: $($aboutDescendants -join ' | ')" + Require ($aboutContent -match "Version\s+\S+") "About dialog omitted the application version: $($aboutDescendants -join ' | ')" + $aboutOk = $aboutDialog.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, + "OK" + )) + ) + Require ($null -ne $aboutOk) "About dialog omitted its OK action" + Require ([GraphCodeUiaGateState]::PostClose( + [IntPtr]$aboutDialog.Current.NativeWindowHandle + )) "About dialog rejected its close command" + Start-Sleep -Milliseconds 250 + + Require $process.CloseMainWindow() "shell refused caption close" + Start-Sleep -Milliseconds 250 + $process.Refresh() + Require (-not $process.HasExited) "caption close terminated the tray-resident shell" + Require ([GraphCodeUiaGateState]::PostCommand($shellWindow, 0x5002)) "tray Exit command was rejected" + Require $process.WaitForExit(5000) "shell did not exit after the tray Exit command" + Require ($process.ExitCode -eq 0) "shell exited with code $($process.ExitCode) during provider teardown" + $retainedProviderSafe = $false + try { + $null = $status.Current.Name + $retainedProviderSafe = $true + } catch [System.Windows.Automation.ElementNotAvailableException] { + $retainedProviderSafe = $true + } + Require $retainedProviderSafe "retained status provider was unsafe after teardown" + $env:GRAPHCODE_UIA_RESET_SIDEBAR = "0" + if ($ArgumentList.Count -gt 0) { + $settingsProcess = Start-Process -FilePath $Shell -ArgumentList $ArgumentList -PassThru ` + -WindowStyle Normal -RedirectStandardError $settingsErrorPath + } else { + $settingsProcess = Start-Process -FilePath $Shell -PassThru -WindowStyle Normal ` + -RedirectStandardError $settingsErrorPath + } + for ($index = 0; $index -lt 160 -and $settingsProcess.MainWindowHandle -eq 0; $index++) { + Start-Sleep -Milliseconds 250 + $settingsProcess.Refresh() + Require (-not $settingsProcess.HasExited) "Product Settings fixture shell exited during startup" + } + Require ($settingsProcess.MainWindowHandle -ne 0) "Product Settings fixture shell did not create its main window" + $settingsRoot = $null + for ($index = 0; $index -lt 160 -and $null -eq $settingsRoot; $index++) { + Start-Sleep -Milliseconds 250 + $settingsProcess.Refresh() + Require (-not $settingsProcess.HasExited) "Product Settings fixture shell exited before UIA readiness" + $candidate = [System.Windows.Automation.AutomationElement]::FromHandle( + $settingsProcess.MainWindowHandle + ) + if ($candidate.Current.AutomationId -eq "graphcode-root") { + $settingsRoot = $candidate + } + } + Require ($null -ne $settingsRoot) "Product Settings fixture shell did not reach UIA readiness" + $settingsFixtureReady = $false + for ($index = 0; $index -lt 160 -and -not $settingsFixtureReady; $index++) { + Start-Sleep -Milliseconds 250 + $settingsWorktrees = Find-FragmentById $settingsRoot "worktrees" $rawWalker + if ($null -ne $settingsWorktrees) { + $settingsFixtureReady = @(Get-DirectChildren $settingsWorktrees $rawWalker).Count -eq 2 + } + } + Require $settingsFixtureReady "Product Settings fixture shell did not finish startup" + $settingsLoops = Find-FragmentById $settingsRoot "loops" $rawWalker + $persistedLoopRows = @(Get-DirectChildren $settingsLoops $rawWalker | Where-Object { + $_.Current.AutomationId -match '^loop-row-' + }) + Require (($persistedLoopRows.Count -eq 2) -and + ((@($persistedLoopRows | ForEach-Object { $_.Current.Name }) -join "|") -eq + "UIA loop A|UIA loop B")) ` + "nested loop expansion did not persist across shell restart" + $settingsShellWindow = $settingsProcess.MainWindowHandle + $settingsMessageLoopReady = $false + for ($attempt = 0; $attempt -lt 20 -and -not $settingsMessageLoopReady; $attempt++) { + $null = [GraphCodeUiaGateState]::PostFixtureMutation($settingsShellWindow, 1) + Start-Sleep -Milliseconds 250 + $settingsRowNames = @(Get-DirectChildren $settingsWorktrees $rawWalker | + ForEach-Object { $_.Current.Name }) + $settingsMessageLoopReady = ($settingsRowNames -join "|") -eq + "C:\fixture-unsafe|C:\fixture-safe" + } + Require $settingsMessageLoopReady "Product Settings fixture shell message loop did not become ready" + Require ([GraphCodeUiaGateState]::PostFixtureMutation($settingsShellWindow, 1)) ` + "Product Settings fixture shell rejected readiness restore" + Start-Sleep -Milliseconds 250 + Start-Sleep -Milliseconds 1500 + $settingsHandle = [IntPtr]::Zero + for ($attempt = 0; $attempt -lt 3 -and + $settingsHandle -eq [IntPtr]::Zero; $attempt++) { + $null = [GraphCodeUiaGateState]::PostFixtureMutation($settingsShellWindow, 15) + Start-Sleep -Milliseconds 500 + for ($index = 0; $index -lt 100 -and + $settingsHandle -eq [IntPtr]::Zero; $index++) { + Start-Sleep -Milliseconds 50 + $settingsHandle = [GraphCodeUiaGateState]::FindTopLevel( + "GraphCodeProductSettings", [uint32]$settingsProcess.Id + ) + } + } + if ($settingsHandle -eq [IntPtr]::Zero) { + $settingsProcess.Refresh() + if ($settingsProcess.HasExited) { + $settingsError = if (Test-Path -LiteralPath $settingsErrorPath) { + Get-Content -LiteralPath $settingsErrorPath -Raw + } else { "" } + throw "Product Settings fixture shell exited with code $($settingsProcess.ExitCode) while opening settings: $settingsError" + } + $processWindowCondition = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ProcessIdProperty, $settingsProcess.Id + ) + $windowNames = @($desktop.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + $processWindowCondition + ) | ForEach-Object { $_.Current.Name }) + $settingsStatus = Find-FragmentById $settingsRoot "status" $controlWalker + throw "Product Settings fixture did not open the real settings window; process windows: $($windowNames -join ', '); status=$($settingsStatus.Current.Name)" + } + $productSettings = [System.Windows.Automation.AutomationElement]::FromHandle($settingsHandle) + $settingsElements = @($productSettings.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition + )) + $settingsNames = @($settingsElements | ForEach-Object { $_.Current.Name }) + $requiredSettingsNames = @( + "New loops use: Claude Code", + "Claude Code: Auto (recommended)", + "Copilot CLI: YOLO (recommended)", + "Codex: Workspace (recommended)", + "Default model: Capable", + "Pick a model for each loop", + "Show the activity strip", + "Tell sessions they're part of a graph", + "Get beta releases", + "Save", + "Cancel" + ) + foreach ($requiredName in $requiredSettingsNames) { + $element = @($settingsElements | Where-Object { $_.Current.Name -eq $requiredName }) | + Select-Object -First 1 + Require ($null -ne $element) "Product Settings omitted '$requiredName'; descendants: $(@($settingsElements | ForEach-Object { ""$($_.Current.ControlType.ProgrammaticName):$($_.Current.Name)"" }) -join ' | ')" + Require (($element.Current.BoundingRectangle.Width -gt 0) -and + ($element.Current.BoundingRectangle.Height -gt 0) -and + (-not $element.Current.IsOffscreen)) "Product Settings hid '$requiredName'" + } + $settingsContent = $settingsNames -join "`n" + foreach ($copy in @( + "Which backend a new loop starts on. You can still change it per loop.", + "Approves the ordinary work of a coding session and keeps its guardrails.", + "Copilot's --yolo: tools, paths, and URLs all approved", + "Runs without asking, and may write inside the project it was given.", + "nobody is there to answer a permission prompt", + "The default model tier is copied into new loops", + "A strip along the window's bottom lists passes", + "Lets a loop create more loops when work genuinely splits", + "Check for Updates offers pre-releases as well as stable releases" + )) { + Require ($settingsContent -match [regex]::Escape($copy)) ` + "Product Settings omitted explanatory copy '$copy'" + } + $expectedToggles = @{ + "Pick a model for each loop" = 1 + "Show the activity strip" = 1 + "Tell sessions they're part of a graph" = 0 + "Get beta releases" = 1 + } + foreach ($entry in $expectedToggles.GetEnumerator()) { + $toggleElement = @($settingsElements | Where-Object { $_.Current.Name -eq $entry.Key }) | + Select-Object -First 1 + Require ([GraphCodeUiaGateState]::GetCheckState( + [IntPtr]$toggleElement.Current.NativeWindowHandle + ) -eq $entry.Value) ` + "Product Settings toggle '$($entry.Key)' did not load the isolated fixture" + } + + $settingsWindow = $settingsHandle + $backendButton = @($settingsElements | Where-Object { + $_.Current.Name -eq "New loops use: Claude Code" + }) | Select-Object -First 1 + Require ($null -ne $backendButton) "Product Settings backend control became unavailable" + Require ([GraphCodeUiaGateState]::SendCommand($settingsWindow, 6112)) ` + "Product Settings backend fixture mutation was rejected" + $null = [GraphCodeUiaGateState]::FocusControl( + $settingsWindow, [IntPtr]$backendButton.Current.NativeWindowHandle + ) + Require ([GraphCodeUiaGateState]::PostKeyboard( + [IntPtr]$backendButton.Current.NativeWindowHandle, 0x0D + )) "Product Settings focused control rejected Return" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingSettings = [GraphCodeUiaGateState]::FindTopLevel( + "GraphCodeProductSettings", [uint32]$settingsProcess.Id + ) + if ($remainingSettings -eq [IntPtr]::Zero) { break } + } + Require ($remainingSettings -eq [IntPtr]::Zero) "Return did not activate Save and close Product Settings" + $savedSettings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json + Require (($savedSettings.defaultBackend -eq "copilotCLI") -and + ($savedSettings.gateSentinel -eq "preserve")) ` + "Return did not save Product Settings or preserve unrelated settings" + $savedSettingsBytes = [IO.File]::ReadAllBytes($settingsPath) + + Require ([GraphCodeUiaGateState]::PostFixtureMutation($settingsShellWindow, 15)) ` + "Product Settings Escape fixture command was rejected" + Start-Sleep -Milliseconds 500 + $cancelHandle = [IntPtr]::Zero + for ($index = 0; $index -lt 300 -and + $cancelHandle -eq [IntPtr]::Zero; $index++) { + Start-Sleep -Milliseconds 50 + $cancelHandle = [GraphCodeUiaGateState]::FindTopLevel( + "GraphCodeProductSettings", [uint32]$settingsProcess.Id + ) + } + Require ($cancelHandle -ne [IntPtr]::Zero) "Product Settings did not reopen for Escape verification" + $cancelSettings = [System.Windows.Automation.AutomationElement]::FromHandle($cancelHandle) + $cancelWindow = $cancelHandle + Require ([GraphCodeUiaGateState]::SendCommand($cancelWindow, 6112)) ` + "Product Settings cancel mutation was rejected" + $cancelModel = $cancelSettings.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::NameProperty, "Default model: Capable" + )) + ) + Require ($null -ne $cancelModel) "Product Settings omitted its model picker on reopen" + $null = [GraphCodeUiaGateState]::FocusControl( + $cancelWindow, [IntPtr]$cancelModel.Current.NativeWindowHandle + ) + Require ([GraphCodeUiaGateState]::PostKeyboard( + [IntPtr]$cancelModel.Current.NativeWindowHandle, 0x1B + )) "Product Settings focused control rejected Escape" + for ($index = 0; $index -lt 40; $index++) { + Start-Sleep -Milliseconds 50 + $remainingCancelSettings = [GraphCodeUiaGateState]::FindTopLevel( + "GraphCodeProductSettings", [uint32]$settingsProcess.Id + ) + if ($remainingCancelSettings -eq [IntPtr]::Zero) { break } + } + Require ($remainingCancelSettings -eq [IntPtr]::Zero) "Escape did not cancel and close Product Settings" + Require (([Convert]::ToBase64String([IO.File]::ReadAllBytes($settingsPath))) -eq + ([Convert]::ToBase64String($savedSettingsBytes))) ` + "Escape changed persisted Product Settings" + Require ([GraphCodeUiaGateState]::PostCommand($settingsShellWindow, 0x5002)) ` + "Product Settings fixture shell rejected exit" + Require $settingsProcess.WaitForExit(5000) "Product Settings fixture shell did not exit" + Require ($settingsProcess.ExitCode -eq 0) "Product Settings fixture shell exited with code $($settingsProcess.ExitCode)" + $settingsProcess = $null + + [pscustomobject]@{ + name = $rootName + automationId = $rootAutomationId + controlType = $rootControlType + rawRootChildren = $rawRootChildIds + controlRootChildren = $controlRootChildIds + rawWorktreeRows = $rawWorktreeRowIds + controlWorktreeRows = $controlWorktreeRowIds + reorderedWorktreeRows = $reorderedRowIds + remainingWorktreeRows = $remainingRowIds + removedProviderUnavailable = $removedProviderUnavailable + concurrencyStressPassed = $true + selectionPattern = $true + fixtureRows = 2 + unsafeSelectionRejected = $unsafeRejected + repeatedSelectionObserved = $true + selectionEvents = $selectionEventEvidence + togglePropertyEvents = [GraphCodeUiaGateState]::TogglePropertyEvents + togglePropertySource = [GraphCodeUiaGateState]::TogglePropertySourceAutomationId + actionPatterns = @($actions.Keys | Sort-Object) + surfaceActionPatterns = @($surfaceActionPatterns.Keys | Sort-Object) + dynamicProjectRows = $projectRowIds + dynamicLoopRows = $loopIds + dynamicProjectCards = $projectCardIds + dynamicQuickChatCards = $quickChatCardIds + dynamicInvocationsPassed = $true + compositeNavigationPassed = $true + renameDialogPassed = $true + jumpPalettePassed = $true + inlineIngressErrorPassed = $true + openFolderPickerPassed = $true + emptyOverviewPassed = $true + emptyProjectPassed = $true + remoteConnectionInfoPassed = $true + deleteProjectLoopsPassed = $true + deleteEdgePassed = $true + productSettingsPassed = $true + productSettingsReturnSaved = ($savedSettings.defaultBackend -eq "copilotCLI") + productSettingsEscapeCancelled = $true + aboutDialogPassed = $true + statusText = $statusTextAfter + statusChanged = ($statusTextAfter -ne $initialStatus) + statusNoChangeLiveEvents = $statusNoChangeLiveEvents + statusNoChangeNameEvents = $statusNoChangeNameEvents + statusLiveEvents = [GraphCodeUiaGateState]::LiveEvents + statusNamePropertyEvents = [GraphCodeUiaGateState]::NamePropertyEvents + statusEventObserved = $statusEventObserved + statusNamePropertyObserved = [GraphCodeUiaGateState]::NamePropertyObserved + statusNamePropertySource = [GraphCodeUiaGateState]::NamePropertySourceAutomationId + statusEventSource = [GraphCodeUiaGateState]::LiveSourceAutomationId + statusEventText = [GraphCodeUiaGateState]::LiveSourceName + focusIdentity = $focusIdentity + focusEventObserved = [GraphCodeUiaGateState]::FocusObserved + initialFocusEventSource = $initialFocusSource + focusFallbackSource = [GraphCodeUiaGateState]::FocusSourceAutomationId + providerTeardownSafe = $retainedProviderSafe + connectionFailureBannerPassed = $true + } | ConvertTo-Json -Compress +} finally { + if ($stressJob) { + Remove-Job -Job $stressJob -Force -ErrorAction SilentlyContinue + } + if ($propertyEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationPropertyChangedEventHandler( + $status, $propertyHandler + ) + } + if ($liveEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler( + $liveRegionEvent, $status, $eventHandler + ) + } + if ($focusEventRegistered) { + [System.Windows.Automation.Automation]::RemoveAutomationFocusChangedEventHandler($focusHandler) + } + if ($process -and -not $process.HasExited) { + $process.Kill() + $process.WaitForExit() + } + if ($settingsProcess -and -not $settingsProcess.HasExited) { + $settingsProcess.Kill() + $settingsProcess.WaitForExit() + } + if ($policyExisted) { + [IO.File]::WriteAllBytes($policyPath, $policyContents) + } elseif ($policyPath) { + Remove-Item -LiteralPath $policyPath -Force -ErrorAction SilentlyContinue + if (-not $policyDirectoryExisted -and + -not (Get-ChildItem -LiteralPath $policyDirectory -Force -ErrorAction SilentlyContinue | + Select-Object -First 1)) { + Remove-Item -LiteralPath $policyDirectory -Force -ErrorAction SilentlyContinue + } + } + if ($settingsDirectory) { + Remove-Item -LiteralPath $settingsDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + if ($null -eq $oldZmx) { Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_ZMX = $oldZmx } + if ($null -eq $oldCwd) { Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_GATE_CWD = $oldCwd } + if ($null -eq $oldGate) { Remove-Item Env:GRAPHCODE_UIA_GATE -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_UIA_GATE = $oldGate } + if ($null -eq $oldConnectionFailure) { + Remove-Item Env:GRAPHCODE_UIA_CONNECTION_FAILURE -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_UIA_CONNECTION_FAILURE = $oldConnectionFailure + } + if ($null -eq $oldUser) { Remove-Item Env:USERNAME -ErrorAction SilentlyContinue } + else { $env:USERNAME = $oldUser } + if ($null -eq $oldFixture) { Remove-Item Env:GRAPHCODE_UIA_FIXTURE_ROWS -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_UIA_FIXTURE_ROWS = $oldFixture } + if ($null -eq $oldDaemonPipe) { Remove-Item Env:GRAPHCODE_DAEMON_PIPE -ErrorAction SilentlyContinue } + else { $env:GRAPHCODE_DAEMON_PIPE = $oldDaemonPipe } + if ($null -eq $oldSupportDirectory) { + Remove-Item Env:GRAPHCODE_SUPPORT_DIR -ErrorAction SilentlyContinue + } else { $env:GRAPHCODE_SUPPORT_DIR = $oldSupportDirectory } + if ($null -eq $oldResetSidebar) { + Remove-Item Env:GRAPHCODE_UIA_RESET_SIDEBAR -ErrorAction SilentlyContinue + } else { $env:GRAPHCODE_UIA_RESET_SIDEBAR = $oldResetSidebar } +} diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 new file mode 100644 index 00000000..f30be78c --- /dev/null +++ b/Tools/windows/validate.ps1 @@ -0,0 +1,714 @@ +[CmdletBinding()] +param( + [ValidateSet( + "all", + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "remote-bridge", + "remote-e2e", + "swift-format", + "visual-baseline", + "tdd-evidence", + "privacy", + "terminal-gate", + "windows-shell", + "packaging", + "hardening" + )] + [string] $Task = "all", + [switch] $List, + [switch] $DryRun, + [switch] $SkipTrayLive, + [switch] $SkipWslRemoteE2E, + [string] $SwiftExecutable +) + +$ErrorActionPreference = "Stop" +$env:GIT_CONFIG_COUNT = "1" +$env:GIT_CONFIG_KEY_0 = "safe.bareRepository" +$env:GIT_CONFIG_VALUE_0 = "all" + +$tasks = @( + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "remote-bridge", + "remote-e2e", + "swift-format", + "visual-baseline", + "tdd-evidence", + "privacy", + "terminal-gate", + "windows-shell", + "packaging", + "hardening" +) + +if ($List) { + $tasks + exit 0 +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") + +function Resolve-SwiftExecutable { + if ($SwiftExecutable) { + return (Resolve-Path $SwiftExecutable).Path + } + + $candidates = @() + $command = Get-Command swift.exe -ErrorAction SilentlyContinue + if ($command) { + $candidates += $command.Source + } + $candidates += Get-ChildItem ` + (Join-Path $env:LOCALAPPDATA "Programs\Swift\Toolchains") ` + -Recurse -Filter swift.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + $candidates += Get-ChildItem ` + "C:\Library\Developer\Toolchains" ` + -Recurse -Filter swift.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + + foreach ($candidate in $candidates | Select-Object -Unique) { + if ($candidate -match "\\Toolchains\\([0-9]+)\.[^\\]*\\usr\\bin\\swift\.exe$" -and + [int] $Matches[1] -ge 6) { + return $candidate + } + $version = & $candidate --version 2>$null | Select-Object -First 1 + if ($version -match "Swift version ([0-9]+)\.") { + if ([int] $Matches[1] -ge 6) { + return $candidate + } + } + } + + throw "Swift 6 or newer was not found. Install the pinned toolchain or pass -SwiftExecutable." +} + +function Initialize-SwiftEnvironment([string] $swift) { + $toolBin = Split-Path $swift + $env:PATH = "$toolBin;$env:PATH" + + if ($swift -match "^(.*)\\Toolchains\\([^\\]+)\\usr\\bin\\swift\.exe$") { + $swiftRoot = $Matches[1] + $toolchainName = $Matches[2] + $version = $toolchainName.Split("+")[0] + $runtimeCandidates = @( + (Join-Path $swiftRoot "Runtimes\$version\usr\bin"), + $toolBin + ) + $sdk = Join-Path $swiftRoot "Platforms\$version\Windows.platform\Developer\SDKs\Windows.sdk" + foreach ($runtime in $runtimeCandidates | Select-Object -Unique) { + if (Test-Path $runtime) { + $env:PATH = "$runtime;$env:PATH" + } + } + if (Test-Path $sdk) { + $env:SDKROOT = $sdk + } + } +} + +function Resolve-SwiftRuntimeDirectory([string] $swift) { + $toolBin = Split-Path $swift + if ($swift -match "^(.*)\\Toolchains\\([^\\]+)\\usr\\bin\\swift\.exe$") { + $swiftRoot = $Matches[1] + $toolchainName = $Matches[2] + $version = $toolchainName.Split("+")[0] + $runtimeCandidates = @( + (Join-Path $swiftRoot "Runtimes\$version\usr\bin"), + $toolBin + ) + foreach ($runtime in $runtimeCandidates | Select-Object -Unique) { + if ((Test-Path $runtime) -and + (Get-ChildItem -LiteralPath $runtime -Filter *.dll -ErrorAction SilentlyContinue)) { + return $runtime + } + } + } + throw "Swift runtime DLL directory was not found for $swift" +} + +function Start-CleanRuntimeProcess( + [string] $executable, + [string[]] $arguments, + [string] $supportDirectory +) { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $executable + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.Environment["PATH"] = Join-Path $env:SystemRoot "System32" + $startInfo.Environment["SystemRoot"] = $env:SystemRoot + $startInfo.Environment["WINDIR"] = $env:WINDIR + $startInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $supportDirectory + foreach ($argument in $arguments) { + [void] $startInfo.ArgumentList.Add($argument) + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Failed to start clean-environment process: $executable" + } + [void] $process.Handle + return $process +} + +function Install-AtomicRuntimePackage([string] $sourceDirectory, [string] $destinationDirectory) { + $files = @( + Get-ChildItem -LiteralPath $sourceDirectory -File | + Where-Object { + $_.Name -in @("graphcoded.exe", "graphcode.exe") -or + $_.Extension -ieq ".dll" + } + ) + if (-not ($files | Where-Object Extension -ieq ".dll")) { + throw "Runtime package has no Swift DLLs: $sourceDirectory" + } + $parent = Split-Path -Parent $destinationDirectory + New-Item -ItemType Directory -Force -Path $parent | Out-Null + $packageRoot = Join-Path $parent ".graphcode-packages" + New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null + $staging = Join-Path $packageRoot "$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path $staging | Out-Null + $version = Join-Path $staging ".graphcode-package.version" + Set-Content -LiteralPath $version -Value ([guid]::NewGuid().ToString()) -NoNewline + $backup = Join-Path $parent ".graphcode-rollback-$([guid]::NewGuid())" + try { + foreach ($file in $files) { + Copy-Item -LiteralPath $file.FullName ` + -Destination (Join-Path $staging $file.Name) -Force + } + if (Test-Path -LiteralPath $destinationDirectory) { + Move-Item -LiteralPath $destinationDirectory -Destination $backup + } + try { + Move-Item -LiteralPath $staging -Destination $destinationDirectory + } catch { + if (Test-Path -LiteralPath $backup) { + Move-Item -LiteralPath $backup -Destination $destinationDirectory + } + throw + } + } finally { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Invoke-Native([string] $description, [scriptblock] $command) { + Write-Host "==> $description" + & $command + if ($LASTEXITCODE -ne 0) { + throw "$description failed with exit code $LASTEXITCODE" + } +} + +function Resolve-ZigVersion([string] $version, [string] $environmentName) { + $candidates = @() + $configured = [Environment]::GetEnvironmentVariable($environmentName) + if ($configured) { + $candidates += $configured + } + $command = Get-Command zig.exe -ErrorAction SilentlyContinue + if ($command) { + $candidates += $command.Source + } + $worktrees = Split-Path (Split-Path $repoRoot -Parent) -Parent + $candidates += Get-ChildItem $worktrees -Recurse -Filter zig.exe ` + -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + + foreach ($candidate in $candidates | Select-Object -Unique) { + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { + continue + } + $resolved = & $candidate version 2>$null + if ($LASTEXITCODE -ne 0 -or $resolved -ne $version) { + continue + } + & $candidate env *> $null + if ($LASTEXITCODE -eq 0) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + throw "Zig $version is required for the pinned Windows provider; set $environmentName." +} + +function Invoke-Task([string] $name) { + if ($DryRun) { + Write-Output "task=$name" + return + } + Write-Host "task=$name" + + $swiftTasks = @( + "swift-portable", + "swift-contracts", + "swift-production", + "swift-paths", + "swift-process", + "swift-named-pipe", + "swift-format" + ) + if ($swiftTasks -contains $name) { + $swift = Resolve-SwiftExecutable + Initialize-SwiftEnvironment $swift + $swiftBin = Split-Path $swift + } + + switch ($name) { + "swift-portable" { + & (Join-Path $repoRoot "investigation\spikes\swift-portable\prepare.ps1") + Invoke-Native "Swift portable-domain tests" { + & (Join-Path $swiftBin "swift-test.exe") ` + --package-path (Join-Path $repoRoot "investigation\spikes\swift-portable") + } + } + "swift-contracts" { + & (Join-Path $repoRoot "investigation\spikes\swift-contracts\prepare.ps1") + Invoke-Native "Swift platform-contract tests" { + & (Join-Path $swiftBin "swift-test.exe") ` + --package-path (Join-Path $repoRoot "investigation\spikes\swift-contracts") + } + } + "swift-production" { + Invoke-Native "Swift production Windows package tests" { + & (Join-Path $swiftBin "swift-test.exe") ` + --package-path $repoRoot + } + foreach ($product in @("graphcoded", "graphcode")) { + Invoke-Native "Swift production release build: $product" { + & (Join-Path $swiftBin "swift-build.exe") ` + --package-path $repoRoot ` + --configuration release ` + --product $product + } + } + $releaseBin = & (Join-Path $swiftBin "swift-build.exe") ` + --package-path $repoRoot ` + --configuration release ` + --show-bin-path + if ($LASTEXITCODE -ne 0) { + throw "Swift production release bin path lookup failed" + } + $releaseBin = $releaseBin | Select-Object -Last 1 + $runtimeDirectory = Resolve-SwiftRuntimeDirectory $swift + $runtimeDLLs = Get-ChildItem -LiteralPath $runtimeDirectory -Filter *.dll + if (-not $runtimeDLLs) { + throw "Swift runtime DLL directory is empty: $runtimeDirectory" + } + foreach ($runtimeDLL in $runtimeDLLs) { + Copy-Item -LiteralPath $runtimeDLL.FullName -Destination $releaseBin -Force + } + Write-Host "Copied $($runtimeDLLs.Count) Swift runtime DLLs to $releaseBin" + foreach ($product in @("graphcoded.exe", "graphcode.exe")) { + $binary = Join-Path $releaseBin $product + if (-not (Test-Path $binary)) { + throw "Swift production binary was not produced: $binary" + } + } + $smokeSupport = Join-Path $repoRoot ".build\windows-clean-runtime-smoke-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force $smokeSupport | Out-Null + $installedBin = Join-Path $smokeSupport "bin" + Install-AtomicRuntimePackage $releaseBin $installedBin + $daemonProcess = $null + $secondDaemonProcess = $null + $cliProcess = $null + try { + $daemonProcess = @(Start-CleanRuntimeProcess ` + (Join-Path $installedBin "graphcoded.exe") @() $smokeSupport)[-1] + if ($null -eq $daemonProcess) { + throw "clean-environment daemon process was not returned" + } + Start-Sleep -Milliseconds 1000 + if ($daemonProcess.HasExited) { + throw "graphcoded.exe exited during clean-environment smoke" + } + + $secondDaemonProcess = @(Start-CleanRuntimeProcess ` + (Join-Path $installedBin "graphcoded.exe") @() $smokeSupport)[-1] + if ($null -eq $secondDaemonProcess) { + throw "second clean-environment daemon process was not returned" + } + $secondStdoutTask = $secondDaemonProcess.StandardOutput.ReadToEndAsync() + $secondStderrTask = $secondDaemonProcess.StandardError.ReadToEndAsync() + if ($null -eq $secondStdoutTask -or $null -eq $secondStderrTask) { + throw "second clean-environment daemon output tasks were not created" + } + if (-not $secondDaemonProcess.WaitForExit(5000)) { + $secondDaemonProcess.Kill() + $secondDaemonProcess.WaitForExit() + throw "second graphcoded.exe did not exit after singleton rejection" + } + $secondDaemonProcess.Refresh() + $secondStderr = $secondStderrTask.Result + if ($secondDaemonProcess.ExitCode -eq 0 -or + $secondStderr -notmatch "already running") { + throw "second graphcoded.exe did not reject the singleton cleanly: $secondStderr" + } + + $cliProcess = @(Start-CleanRuntimeProcess ` + (Join-Path $installedBin "graphcode.exe") @("projects") $smokeSupport)[-1] + if ($null -eq $cliProcess) { + throw "clean-environment CLI process was not returned" + } + $stdoutTask = $cliProcess.StandardOutput.ReadToEndAsync() + $stderrTask = $cliProcess.StandardError.ReadToEndAsync() + if ($null -eq $stdoutTask -or $null -eq $stderrTask) { + throw "clean-environment CLI output tasks were not created" + } + $cliProcess.WaitForExit() + $cliProcess.Refresh() + $stdout = $stdoutTask.Result + $stderr = $stderrTask.Result + if ($cliProcess.ExitCode -ne 0) { + throw "clean-environment graphcode.exe failed: $stderr" + } + Write-Host "Clean-environment daemon/CLI bootstrap smoke passed" + } finally { + if ($cliProcess -and -not $cliProcess.HasExited) { + $cliProcess.Kill() + $cliProcess.WaitForExit() + } + if ($secondDaemonProcess -and -not $secondDaemonProcess.HasExited) { + $secondDaemonProcess.Kill() + $secondDaemonProcess.WaitForExit() + } + if ($daemonProcess -and -not $daemonProcess.HasExited) { + $daemonProcess.Kill() + $daemonProcess.WaitForExit() + } + Remove-Item -LiteralPath $smokeSupport -Recurse -Force -ErrorAction SilentlyContinue + } + } + "swift-paths" { + Invoke-Native "Swift Windows path spike" { + & (Join-Path $swiftBin "swift-run.exe") ` + --package-path (Join-Path $repoRoot "investigation\spikes\swift-paths") + } + } + "swift-process" { + Invoke-Native "Swift Windows process spike" { + & (Join-Path $swiftBin "swift-run.exe") ` + --package-path (Join-Path $repoRoot "investigation\spikes\swift-process") + } + } + "swift-named-pipe" { + Invoke-Native "Swift Named Pipe spike" { + & (Join-Path $swiftBin "swift-run.exe") ` + --package-path (Join-Path $repoRoot "investigation\spikes\swift-named-pipe") + } + } + "remote-bridge" { + $python = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $python) { + throw "Python 3 was not found for the remote-bridge fixture" + } + Invoke-Native "Python remote bridge proof" { + & $python.Source -B ` + (Join-Path $repoRoot "investigation\spikes\remote-bridge\run_tests.py") + } + & (Join-Path $repoRoot "Tools\windows\Tests\RemoteBridgePrivacyRace.Tests.ps1") + if ($LASTEXITCODE -ne 0) { + throw "Remote bridge privacy race regression failed with exit code $LASTEXITCODE" + } + } + "remote-e2e" { + $python = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $python) { + throw "Python 3 was not found for the remote E2E fixture" + } + $arguments = @( + "-B", + (Join-Path $repoRoot "investigation\spikes\remote-e2e\test_remote_e2e.py"), + "-v" + ) + if ($SkipWslRemoteE2E) { + $arguments += "--skip-local-wsl" + } + Invoke-Native "Windows-to-POSIX remote E2E parity" { + & $python.Source @arguments + } + } + "swift-format" { + $formatter = Join-Path $swiftBin "swift-format.exe" + if (-not (Test-Path $formatter)) { + throw "swift-format.exe was not found next to $swift" + } + $sources = Get-ChildItem ` + (Join-Path $repoRoot "investigation\spikes") ` + -Recurse -Filter *.swift | + Where-Object { + $_.FullName -notmatch "[\\/]\.build[\\/]" -and + $_.FullName -notmatch + "[\\/]swift-(full|portable|contracts)[\\/]Sources[\\/]" + } | + Select-Object -ExpandProperty FullName + $sources += Get-ChildItem ` + (Join-Path $repoRoot "GraphcodeKit\Sources\Platform") ` + -Filter *.swift | + Select-Object -ExpandProperty FullName + $sources += @( + (Join-Path $repoRoot "GraphcodeKit\Sources\SupportDirectory.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\ProjectPersistence.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\IPC\WindowsNamedPipeTransport.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\IPC\WindowsRemoteBridge.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\IPC\DaemonConnectionChannel.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\IPC\DaemonSocketClient.swift"), + (Join-Path $repoRoot "GraphcodeKit\Sources\IPC\DaemonSocketPath.swift"), + (Join-Path $repoRoot "graphcoded\Sources\main.swift"), + (Join-Path $repoRoot "windows-tests\WindowsDaemonTests.swift"), + (Join-Path $repoRoot ` + "investigation\spikes\swift-full\Tests\GraphcodeKitWindowsTests\PlatformTests.swift") + ) + foreach ($source in $sources) { + $temporary = Join-Path $env:TEMP "graphcode-format-$([guid]::NewGuid()).swift" + try { + $content = [IO.File]::ReadAllText($source).Replace("`r`n", "`n") + [IO.File]::WriteAllText( + $temporary, + $content, + [Text.UTF8Encoding]::new($false) + ) + Invoke-Native "Swift formatting: $source" { + & $formatter lint --strict ` + --configuration (Join-Path $repoRoot ".swift-format") ` + $temporary + } + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } + } + } + "visual-baseline" { + Invoke-Native "Windows visual baseline contract" { + & (Join-Path $repoRoot "Tools\windows\Tests\VisualBaseline.Tests.ps1") + } + } + "tdd-evidence" { + & (Join-Path $repoRoot "Tools\tdd\Tests\TddEvidence.Tests.ps1") + if ($LASTEXITCODE -ne 0) { + throw "TDD evidence tests failed with exit code $LASTEXITCODE" + } + } + "privacy" { + $files = Get-ChildItem (Join-Path $repoRoot "investigation") -Recurse -File | + Where-Object { + $_.FullName -notmatch "[\\/]\.build[\\/]" -and + $_.FullName -notmatch "[\\/]\.zig-cache[\\/]" -and + $_.FullName -notmatch "[\\/]zig-out[\\/]" + } + $streams = foreach ($file in $files) { + Get-Item -LiteralPath $file.FullName -Stream * -ErrorAction SilentlyContinue | + Where-Object Stream -notin @(':$DATA', 'sec.endpointdlp') + } + if ($streams) { + throw "Investigation files contain non-default NTFS streams." + } + + $generated = Get-ChildItem ` + (Join-Path $repoRoot "investigation\spikes") ` + -Recurse -File | + Where-Object { + $_.FullName -notmatch "[\\/]\.build[\\/]" -and + $_.FullName -notmatch "[\\/]\.zig-cache[\\/]" -and + $_.FullName -notmatch "[\\/]zig-out[\\/]" + } | + Where-Object { + $_.Extension -in ".exe", ".obj", ".lib", ".exp", ".log" -or + $_.Name -eq "ready.txt" + } + if ($generated) { + throw "Generated spike artifacts remain under investigation/spikes." + } + + $forbidden = @( + [regex]::Escape($repoRoot.Path), + [regex]::Escape($env:USERPROFILE), + "GraphCode-worktrees", + "Visual Studio\\[0-9]{4}\\(Enterprise|BuildTools)" + ) + foreach ($file in $files) { + if ($file.Extension -notin + ".md", ".swift", ".c", ".py", ".ps1", ".resolved", ".json", ".txt" -and + $file.Name -ne ".gitignore") { + continue + } + try { + $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction Stop + } catch { + if ($_.Exception -is [System.Management.Automation.ItemNotFoundException] -or + $_.Exception -is [System.IO.FileNotFoundException]) { + continue + } + throw + } + foreach ($pattern in $forbidden) { + if ($content -match $pattern) { + throw "Environment-specific content matched '$pattern' in $($file.FullName)" + } + } + } + Write-Host "Privacy checks passed" + } + "terminal-gate" { + & (Join-Path $repoRoot "Tools\windows\Tests\TerminalGate.Tests.ps1") + if ($LASTEXITCODE -ne 0) { + throw "Windows terminal gate contract failed with exit code $LASTEXITCODE" + } + $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" + } + $zmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") + if (-not $zmxRoot) { + $zmxRoot = Join-Path $depotRoot "zmx-worktrees\attach" + } + if (-not (Test-Path -LiteralPath $winghosttyRoot -PathType Container) -or + -not (Test-Path -LiteralPath $zmxRoot -PathType Container)) { + throw "Windows terminal gate provider worktrees unavailable; real smoke is mandatory." + } + $zig0152 = Resolve-ZigVersion "0.15.2" "GRAPHCODE_ZIG0152" + $zig0160 = Resolve-ZigVersion "0.16.0" "GRAPHCODE_ZIG0160" + Invoke-Native "Pinned Windows terminal gate build and smoke" { + & (Join-Path $repoRoot "Tools\windows\terminal-gate.ps1") ` + -WinghosttyRoot $winghosttyRoot ` + -ZmxRoot $zmxRoot ` + -Zig0152 $zig0152 ` + -Zig0160 $zig0160 ` + -Stress + } + } + "windows-shell" { + $zig0152 = Resolve-ZigVersion "0.15.2" "GRAPHCODE_ZIG0152" + $swift = Resolve-SwiftExecutable + Initialize-SwiftEnvironment $swift + $swiftBin = Split-Path $swift + foreach ($product in @("graphcoded", "graphcode")) { + Invoke-Native "Swift release build for shell daemon handoff: $product" { + & (Join-Path $swiftBin "swift-build.exe") ` + --package-path $repoRoot ` + --configuration release ` + --product $product + } + } + $daemonRuntime = & (Join-Path $swiftBin "swift-build.exe") ` + --package-path $repoRoot ` + --configuration release ` + --show-bin-path + if ($LASTEXITCODE -ne 0) { + throw "Swift release bin path lookup for shell daemon handoff failed" + } + $daemonRuntime = $daemonRuntime | Select-Object -Last 1 + $swiftRuntime = Resolve-SwiftRuntimeDirectory $swift + Get-ChildItem -LiteralPath $swiftRuntime -Filter *.dll | + Copy-Item -Destination $daemonRuntime -Force + & (Join-Path $repoRoot "Tools\windows\Tests\WindowsShell.Tests.ps1") ` + -ZigExecutable $zig0152 + if ($LASTEXITCODE -ne 0) { + throw "Windows shell scaffold contract failed with exit code $LASTEXITCODE" + } + $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" + } + $zmxRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_ZMX_ROOT") + if (-not $zmxRoot) { + $zmxRoot = Join-Path $depotRoot "zmx-worktrees\quickchat-hang" + } + if (-not (Test-Path -LiteralPath $winghosttyRoot -PathType Container) -or + -not (Test-Path -LiteralPath $zmxRoot -PathType Container)) { + throw "Windows shell provider worktrees unavailable; real smoke is mandatory." + } + $zig0160 = Resolve-ZigVersion "0.16.0" "GRAPHCODE_ZIG0160" + Invoke-Native "Pinned GraphCode Windows shell build and smoke" { + & (Join-Path $repoRoot "Tools\windows\windows-shell.ps1") ` + -WinghosttyRoot $winghosttyRoot ` + -ZmxRoot $zmxRoot ` + -Zig0152 $zig0152 ` + -Zig0160 $zig0160 ` + -DaemonRuntimeDirectory $daemonRuntime ` + -UseStubDaemon ` + -SkipTrayLive:$SkipTrayLive ` + -Stress + } + & (Join-Path $repoRoot "Tools\windows\Tests\TrayDaemon.Tests.ps1") ` + -Executable (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") + if ($LASTEXITCODE -ne 0) { + throw "Tray daemon executable tests failed with exit code $LASTEXITCODE" + } + Invoke-Native "Native UI Automation live gate" { + & (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") ` + -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") + } + } + "packaging" { + & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Tests.ps1") + if ($LASTEXITCODE -ne 0) { + throw "Windows packaging tests failed with exit code $LASTEXITCODE" + } + } + "hardening" { + if ($env:GRAPHCODE_HARDENING_TARGET) { + & (Join-Path $repoRoot "Tools\windows\Tests\Hardening.Tests.ps1") -Environment + } else { + & (Join-Path $repoRoot "Tools\windows\Tests\Hardening.Tests.ps1") + } + if ($LASTEXITCODE -ne 0) { + throw "Windows hardening tests failed with exit code $LASTEXITCODE" + } + } + } +} + +$selected = if ($Task -eq "all") { $tasks } else { @($Task) } +try { + foreach ($name in $selected) { + Invoke-Task $name + } +} finally { + $junctions = @() + if ($selected -contains "swift-portable") { + $junctions += Join-Path $repoRoot ` + "investigation\spikes\swift-portable\Sources\GraphcodePortableDomain" + } + if ($selected -contains "swift-contracts") { + $junctions += @( + (Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\Domain"), + (Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\IPC"), + (Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\Platform") + ) + $junctions += Join-Path $repoRoot ` + "investigation\spikes\swift-contracts\Sources\GraphcodeWindowsContracts\SupportDirectory.swift" + } + foreach ($junction in $junctions) { + if (Test-Path -LiteralPath $junction) { + $item = Get-Item -LiteralPath $junction -Force + if ($item.PSIsContainer) { + [System.IO.Directory]::Delete($item.FullName, $false) + } else { + [System.IO.File]::Delete($item.FullName) + } + } + } +} diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md new file mode 100644 index 00000000..3cf54490 --- /dev/null +++ b/Tools/windows/validation-matrix.md @@ -0,0 +1,106 @@ +# Windows port validation matrix + +The Windows port must have runnable commands before implementation fleets begin. + +## GraphCode + +| Surface | Command | +|---|---| +| Windows investigation spikes | `pwsh Tools/windows/validate.ps1 -Task all` | +| Validation runner contract | `pwsh Tools/windows/Tests/ValidationRunner.Tests.ps1` | +| Deterministic visual baseline | `pwsh Tools/windows/validate.ps1 -Task visual-baseline` | +| TDD evidence contract | `pwsh Tools/tdd/Tests/TddEvidence.Tests.ps1` | +| Platform/wire contracts | `pwsh Tools/windows/validate.ps1 -Task swift-contracts` | +| Authenticated remote bridge proof | `pwsh Tools/windows/validate.ps1 -Task remote-bridge` | +| Windows-to-POSIX remote E2E parity | `pwsh Tools/windows/validate.ps1 -Task remote-e2e` | +| Hosted Windows without a WSL distribution | `pwsh Tools/windows/validate.ps1 -Task all -SkipWslRemoteE2E` | +| Production Swift platform package | `pwsh Tools/windows/validate.ps1 -Task swift-production` | +| Deterministic release hardening fixtures | `pwsh Tools/windows/validate.ps1 -Task hardening` | +| Shared Swift package | `swift test --package-path ` once extracted | +| macOS app/daemon/CLI | `make test` | +| macOS format/lint | `make check` | +| DCO commit range | `git log --format=%B ..HEAD` plus trailer validation | + +## Winghostty fork + +The fork bootstrap must replace these placeholders with exact pinned commands before +extraction work begins: + +| Surface | Required command | +|---|---| +| Original application build | pinned Zig build command | +| Full test suite | pinned Zig test command | +| Interactive Win32 smoke | worktree-isolated smoke harness | +| Embeddable host | external one/two-surface tests after the package exists | + +## zmx fork + +The fork bootstrap must replace these placeholders with exact pinned commands before the +Windows backend fleet begins: + +| Surface | Required command | +|---|---| +| Existing upstream tests | pinned Zig test command | +| GraphCode mouse behavior | focused parser/input tests | +| Windows CLI compatibility | black-box `run/attach/send/get/set/kill` suite | +| Agent compatibility | controlled real-agent smoke tier | + +## Remote SSH + +Remote validation uses controlled POSIX hosts and sanitized fixtures: + +- local bridge unit/security tests; +- mandatory-by-default deterministic local Windows-to-POSIX parity fixture covering setup, + fan-out, messaging, reconnect, restart/reboot restoration, multiple hosts, + generation monotonicity, and capability non-disclosure; +- Python shim protocol fixtures; +- SSH reconnect and stale-state tests; +- manual or protected CI tier for real remote-host execution. Set + `GRAPHCODE_REMOTE_E2E_TARGETS` to comma-separated authenticated `user@host:port` + values; configured targets are mandatory and failures fail the run. Empty entries + are rejected. + +Public GitHub-hosted Windows runners do not provide a configured WSL distribution. +Those workflows pass `-SkipWslRemoteE2E` explicitly, which skips only the WSL-backed +local fixtures; parser and protocol-independent remote E2E checks still run. Developer +and WSL-capable validation defaults remain fail-closed and run the complete fixture. + +Credentials, hostnames, and capability tokens belong in runner secrets and never in the +repository. + +## Final hardening executable matrix + +The `hardening` task is mandatory and always runs deterministic local fixtures; it +does not turn into a pass when an environment is unavailable. The current measured +ceilings are: + +| Dimension | Fixture and threshold | +|---|---| +| High output/backpressure | 4 MiB lossless output in <=10 seconds | +| Long duration | 3-second session completes in 2.5-10 seconds | +| Crash recovery | exit 17 is observed, then a fresh session succeeds | +| Multi-terminal | four concurrent 512 KiB sessions complete in <=15 seconds | +| Unicode/hostile paths | UTF-8 clipboard text round-trips through a >=180-character Unicode path | +| Process cleanup | fixture process count returns to baseline | +| Real product output/session | pinned zmx/ConPTY writes exactly 4 MiB plus a completion marker; session exit code is 0 within 15 seconds | +| Real resource ceilings | private memory <=512 MiB; per-run handle growth <=256; three-run private-memory range <=32 MiB and handle range <=64 | +| Repeatability | three consecutive runs; each reports process, handle, and private-memory deltas and all must pass | + +GPU/WGL, real ConPTY/zmx reconnect, screen reader/UIA, physical DPI/display, +authenticated SSH, login/reboot, and installer ACL tests are environment-only. +They are explicitly gated by `Hardening.Tests.ps1 -Environment` and a runner-owned +PowerShell harness supplied through `GRAPHCODE_HARDENING_TARGET` (the repository +fixture is `Tools/windows/Tests/EnvironmentFixture.ps1`); selecting that +tier without the harness fails. The +deterministic tier remains mandatory on every pull request. The scheduled full +workflow runs the pinned provider/package lifecycle gate and cannot substitute a +skip for a missing provider. + +The environment harness emits `schemaVersion=1` JSON with one result for every +mandatory dimension. A skipped dimension must include a non-empty reason. +Hardening includes RED checks proving missing dimensions and reasonless skips are +rejected; the deterministic Named Pipe fixture is reported separately. + +The Windows host cannot execute macOS tests. `.github/workflows/macos-shared-regression.yml` +is the authoritative macOS matrix (`swift test`, `make test`, `make check`); only +the portable Swift package is executable locally on Windows. diff --git a/Tools/windows/visual-baseline.ps1 b/Tools/windows/visual-baseline.ps1 new file mode 100644 index 00000000..e892d977 --- /dev/null +++ b/Tools/windows/visual-baseline.ps1 @@ -0,0 +1,230 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$manifestPath = Join-Path $repoRoot "investigation\visual-baseline\manifest.json" + +function Assert-Contract([object] $condition, [string] $message) { + $values = @($condition) + if ($values.Count -ne 1 -or -not [bool] $values[0]) { + throw "Visual baseline contract: $message" + } +} + +function Utc-Stamp([object] $value) { + $date = if ($value -is [DateTime]) { + $value.ToUniversalTime() + } else { + [DateTime]::Parse( + [string] $value, + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::AdjustToUniversal) + } + $date.ToString( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + [Globalization.CultureInfo]::InvariantCulture) +} + +Assert-Contract (Test-Path -LiteralPath $manifestPath) ` + "manifest is missing at $manifestPath" + +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +Assert-Contract ($manifest.schemaVersion -eq 1) "schemaVersion must be 1" +Assert-Contract ($manifest.id -eq "graphcode.windows.visual-baseline") ` + "manifest id is not stable" +Assert-Contract ($manifest.baseCommit -eq "ece55b6") ` + "manifest must be based on CONTRACT_BASE ece55b6" +Assert-Contract ((Utc-Stamp $manifest.clock) -eq "2026-01-15T15:00:00Z") ` + "clock must be the fixed UTC fixture time" + +$manifestText = Get-Content -LiteralPath $manifestPath -Raw +foreach ($pattern in @( + "[A-Za-z]:\\", + "(?i)GraphCode-worktrees", + "(?i)(?:^|[\\/])Users[\\/]", + "(?i)(?:^|[\\/])home[\\/]", + "(?i)[\\/]private[\\/]" + )) { + Assert-Contract ($manifestText -notmatch $pattern) ` + "manifest contains an environment-specific path matching '$pattern'" +} + +$sourceReferences = @($manifest.sourceReferences) +Assert-Contract ($sourceReferences.Count -ge 10) "public source references are incomplete" +foreach ($source in $sourceReferences) { + Assert-Contract (Test-Path -LiteralPath (Join-Path $repoRoot $source)) ` + "source reference does not exist: $source" +} + +foreach ($screenshot in @($manifest.screenshotSources)) { + $path = Join-Path $repoRoot $screenshot.path + Assert-Contract (Test-Path -LiteralPath $path) ` + "public screenshot source does not exist: $($screenshot.path)" + $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() + Assert-Contract ($hash -eq $screenshot.sha256) ` + "screenshot hash changed: $($screenshot.path)" +} + +$requiredTokens = @( + "Theme.windowTone", + "Theme.windowBackground", + "Theme.canvasBackground", + "Theme.canvasTone", + "Theme.canvasGridLine", + "Theme.unfocusedPaneVeil", + "Theme.terminalBackgroundOpacity", + "Theme.workspaceRail", + "Theme.paneFocusTint", + "LoopCardView.Metrics.size", + "LoopCardView.Metrics.radius", + "LoopCardView.Metrics.stripe", + "LoopWorkspaceRail.width", + "PaneHeaderView.height", + "CanvasAttentionRail.reviewShortcut" +) +$tokens = @($manifest.tokenContracts | ForEach-Object { $_.name }) +foreach ($token in $requiredTokens) { + Assert-Contract ($tokens -contains $token) "required token is missing: $token" +} + +$requiredStates = @( + "idle", "running", "awaitingInput", "blocked", "succeeded", "failed", + "stalled", "waiting", "stopped" +) +$requiredTypes = @("goalBased", "timeBased", "turnBased", "composite") +Assert-Contract ($manifest.graph.id -eq "00000000-0000-4000-8000-000000000001") ` + "graph ID must be fixed" +Assert-Contract ($manifest.graph.project.path -eq "graphcode://fixtures/windows-visual-baseline") ` + "local fixture project path must be synthetic" +$nodes = @($manifest.graph.nodes) +Assert-Contract ($nodes.Count -eq 9) "the graph must contain nine fixed card states" +$nodeIDs = @($nodes | ForEach-Object { $_.id }) +Assert-Contract (($nodeIDs | Sort-Object -Unique).Count -eq $nodeIDs.Count) ` + "graph node IDs must be unique" +foreach ($node in $nodes) { + Assert-Contract ($node.id -match "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") ` + "node ID is not a stable UUID: $($node.id)" + Assert-Contract ($requiredStates -contains $node.state) ` + "unknown card state: $($node.state)" + Assert-Contract ($requiredTypes -contains $node.type) ` + "unknown loop type: $($node.type)" + Assert-Contract ((Utc-Stamp $node.createdAt) -match "Z$") ` + "node time must be UTC: $($node.id)" + Assert-Contract (@($node.metricHistory).Count -ge 0) ` + "metric history must be present: $($node.id)" +} +foreach ($state in $requiredStates) { + Assert-Contract (@($nodes | Where-Object state -eq $state).Count -eq 1) ` + "card state must appear exactly once: $state" +} +foreach ($type in $requiredTypes) { + Assert-Contract (@($nodes | Where-Object type -eq $type).Count -ge 1) ` + "loop type is not represented: $type" +} + +$expectedWords = @{ + idle = "SCHEDULED" + running = "RUNNING" + awaitingInput = "NEEDS YOU" + blocked = "BLOCKED" + succeeded = "DONE" + failed = "FAILED" + stalled = "STALLED" + waiting = "WAITING" + stopped = "STOPPED" +} +foreach ($node in $nodes) { + Assert-Contract ($node.displayWord -eq $expectedWords[$node.state]) ` + "state word does not match LoopStateAppearance: $($node.id)" + Assert-Contract ($node.PSObject.Properties.Name -contains "metricHistory") ` + "metrics must be explicit, even when empty: $($node.id)" +} +$metricNodes = @($nodes | Where-Object { @($_.metricHistory).Count -ge 2 }) +Assert-Contract ($metricNodes.Count -ge 3) "fixed metric samples are incomplete" +foreach ($node in $metricNodes) { + $values = @($node.metricHistory | ForEach-Object { $_.value }) + Assert-Contract ($values.Count -ge 2) "metric needs two fixed samples: $($node.id)" + foreach ($sample in @($node.metricHistory)) { + Assert-Contract ((Utc-Stamp $sample.recordedAt) -match "Z$") ` + "metric time must be UTC: $($node.id)" + } +} + +$rail = $manifest.canvas.attentionRail +Assert-Contract ($rail.count -eq 4) "attention rail count must be four" +Assert-Contract ($rail.reviewShortcut -eq "⌘⇧R") "attention shortcut changed" +Assert-Contract ($rail.oldestAge -eq "1h 30m") "attention clock is not deterministic" +$reasons = @("failed", "stalled", "awaitingInput", "blocked") +foreach ($reason in $reasons) { + Assert-Contract (@($rail.items | Where-Object reason -eq $reason).Count -eq 1) ` + "attention rail is missing reason: $reason" +} + +$remote = $manifest.sidebar.remoteIndicator +Assert-Contract ($remote.glyph -eq "network") "remote indicator must use network glyph" +$remoteProject = @($manifest.sidebar.rows | Where-Object { $_.remote -eq $true }) +Assert-Contract ($remoteProject.Count -ge 1) "remote sidebar row is missing" +Assert-Contract (($remoteProject | Where-Object { $_.path -match "^ssh://fixture\.example/" }).Count -ge 1) ` + "remote fixture must use a public synthetic SSH path" +Assert-Contract ($manifest.workspace.remote -eq $true) "remote workspace state is missing" + +Assert-Contract ($manifest.workspace.rail.visible -eq $true) "workspace rail is not covered" +Assert-Contract ($manifest.workspace.rail.width -eq 212) "workspace rail width changed" +$directions = @( + $manifest.workspace.tabs | + ForEach-Object { $_.root } | + ForEach-Object { + if ($_.kind -eq "split") { + $_.direction + $_.children | ForEach-Object { $_.direction } + } + } +) +Assert-Contract ($directions -contains "horizontal") "horizontal split fixture is missing" +Assert-Contract ($directions -contains "vertical") "vertical split fixture is missing" + +$dpi = @($manifest.dpiVariants) +foreach ($variantID in @("100", "125", "150", "200")) { + Assert-Contract (@($dpi | Where-Object id -eq $variantID).Count -eq 1) ` + "DPI variant is missing: $variantID" +} +foreach ($variant in $dpi) { + Assert-Contract ($variant.scale -gt 0) "DPI scale must be positive: $($variant.id)" + Assert-Contract ($variant.viewport.width -gt 0 -and $variant.viewport.height -gt 0) ` + "DPI viewport must be positive: $($variant.id)" +} + +$regions = @($manifest.regions) +$deterministicIDs = @($manifest.renderingBoundary.deterministicScreenshotRegions) +$liveIDs = @($manifest.renderingBoundary.liveWinghosttyFunctionalTests) +Assert-Contract ($deterministicIDs.Count -ge 6) "GraphCode screenshot regions are incomplete" +Assert-Contract ($liveIDs.Count -ge 2) "Winghostty functional boundary is incomplete" +Assert-Contract ((@($deterministicIDs | Where-Object { $liveIDs -contains $_ })).Count -eq 0) ` + "deterministic and live region sets must be disjoint" +foreach ($id in $deterministicIDs) { + $region = @($regions | Where-Object id -eq $id) + Assert-Contract ($region.Count -eq 1 -and $region[0].owner -eq "GraphCode" -and + $region[0].kind -eq "deterministic") ` + "screenshot region is not GraphCode-owned: $id" +} +foreach ($id in $liveIDs) { + $region = @($regions | Where-Object id -eq $id) + Assert-Contract ($region.Count -eq 1 -and $region[0].owner -eq "Winghostty" -and + $region[0].kind -eq "live-functional") ` + "live region is not Winghostty-owned: $id" +} + +foreach ($snapshot in @($manifest.terminalSnapshots)) { + $path = Join-Path $repoRoot $snapshot.path + Assert-Contract (Test-Path -LiteralPath $path) ` + "terminal snapshot does not exist: $($snapshot.path)" + $text = Get-Content -LiteralPath $path -Raw + Assert-Contract ($text.Trim().Length -gt 0) "terminal snapshot is empty: $($snapshot.id)" + Assert-Contract ($text -notmatch "[A-Za-z]:\\" -and $text -notmatch "(?i)GraphCode-worktrees") ` + "terminal snapshot contains an environment-specific path: $($snapshot.id)" +} + +Write-Output "Visual baseline: PASS" +exit 0 diff --git a/Tools/windows/windows-shell.ps1 b/Tools/windows/windows-shell.ps1 new file mode 100644 index 00000000..2cb65b62 --- /dev/null +++ b/Tools/windows/windows-shell.ps1 @@ -0,0 +1,469 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $WinghosttyRoot, + [Parameter(Mandatory)] + [string] $ZmxRoot, + [string] $Zig0152 = "zig", + [string] $Zig0160 = "zig", + [string] $DaemonRuntimeDirectory, + [switch] $SkipBuild, + [switch] $SkipTrayLive, + [switch] $Stress, + [switch] $UseStubDaemon, + [string] $Version +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$shellRoot = Join-Path $repoRoot "graphcode-windows" +$packageManifest = Get-Content (Join-Path $shellRoot "build.zig.zon") -Raw +if (-not $Version) { + if ($packageManifest -notmatch '(?m)\.version\s*=\s*"([^"]+)"') { + throw "GraphCode Windows shell package version is missing" + } + $Version = $Matches[1] +} +$pins = Get-Content (Join-Path $shellRoot "provider-pins.json") -Raw | ConvertFrom-Json +$app = Join-Path $shellRoot "zig-out\bin\graphcode-windows.exe" +$stubProcess = $null +$busyStubProcess = $null +$testSessionIds = @( + [guid]::NewGuid().ToString(), + [guid]::NewGuid().ToString() +) +$sessionPrefix = "gs-$([guid]::NewGuid().ToString('N'))" +$stubResult = Join-Path $shellRoot "stub-result-$PID.json" +$busyResult = Join-Path $shellRoot "busy-stub-result-$PID.json" +$busyError = Join-Path $shellRoot "busy-stub-error-$PID.txt" +$inputError = Join-Path $shellRoot "input-smoke-error-$PID.txt" +$oldPipe = [Environment]::GetEnvironmentVariable("GRAPHCODE_DAEMON_PIPE") +$oldRequireDaemon = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_REQUIRE_DAEMON") +$oldNonreadingAttach = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_NONREADING_ATTACH") +$oldLargePaste = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_LARGE_PASTE") +$oldWorkspaceActions = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_WORKSPACE_ACTIONS") +$oldWorkspaceLayout = [Environment]::GetEnvironmentVariable("GRAPHCODE_WORKSPACE_LAYOUT") +$oldStubNodeA = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_A") +$oldStubNodeB = [Environment]::GetEnvironmentVariable("GRAPHCODE_STUB_NODE_B") +$oldSessionPrefix = [Environment]::GetEnvironmentVariable("GRAPHCODE_SHELL_SESSION_PREFIX") +$workspaceLayoutBase = Join-Path $shellRoot "graphcode-workspace-$PID.json" +$ownedSessionNames = [System.Collections.Generic.HashSet[string]]::new() +$ownedProcessIds = [System.Collections.Generic.HashSet[int]]::new() +$shellProcess = $null +$handoffStage = $null +$resourceRole = "graphcode-windows" +$metricSequence = 0 + +function Invoke-Native([string] $description, [scriptblock] $command) { + Write-Host "==> $description" + & $command + if ($LASTEXITCODE -ne 0) { + throw "$description failed with exit code $LASTEXITCODE" + } +} + +function Assert-Equal([string] $actual, [string] $expected, [string] $label) { + if ($actual -ne $expected) { + throw "$label expected $expected but found $actual" + } +} + +function Test-TestSessionProcess([object] $process) { + if (-not $process.CommandLine) { return $false } + foreach ($session in $testSessionIds) { + if ($process.CommandLine -like "*$session*") { return $true } + } + if ($process.CommandLine -like "*$sessionPrefix*") { return $true } + + return $false +} + +function Get-ZmxSessionRecords { + @(& $env:GRAPHCODE_ZMX list 2>$null | ForEach-Object { + if ($_ -match "name=([^\s]+)\s+pid=(\d+)") { + [pscustomobject]@{ Name = $Matches[1]; Pid = [int] $Matches[2] } + } + }) +} + +function Record-TestOwnedSessions { + foreach ($record in @(Get-ZmxSessionRecords)) { + if (($testSessionIds -contains $record.Name) -or + $record.Name.StartsWith($sessionPrefix, [StringComparison]::Ordinal)) { + [void] $ownedSessionNames.Add($record.Name) + [void] $ownedProcessIds.Add($record.Pid) + } + + } +} + +function Write-OwnedResourceMetrics([string] $phase, [int[]] $focusPids = @()) { + $script:metricSequence++ + $metricPids = if ($focusPids.Count -gt 0) { + @(Get-ProcessTreeIds $focusPids) + } else { @($ownedProcessIds) } + $metrics = @($metricPids | ForEach-Object { + $p = Get-Process -Id $_ -ErrorAction SilentlyContinue + if ($p) { + [pscustomobject]@{ + pid = $_ + role = if ($p.ProcessName -match "zmx") { "zmx" } elseif ($_.Equals($script:shellProcess.Id)) { $resourceRole } else { $p.ProcessName } + handles = [int64]$p.HandleCount + privateBytes = [int64]$p.PrivateMemorySize64 + } + + } + }) + Write-Host ("PRODUCT_RESOURCE_METRICS_JSON=" + (@{ + snapshotId = "$sessionPrefix-$resourceRole-$script:metricSequence" + phase = $phase + sessions = @($ownedSessionNames) + processes = $metrics + } | ConvertTo-Json -Compress -Depth 5)) +} + +function Invoke-ShellProcess([string[]] $arguments, [string] $phase) { + $script:shellProcess = Start-Process -FilePath $app -ArgumentList $arguments -PassThru -WindowStyle Hidden + [void] $ownedProcessIds.Add($script:shellProcess.Id) + Start-Sleep -Milliseconds 250 + Record-TestOwnedSessions + Write-OwnedResourceMetrics $phase + $script:shellProcess.WaitForExit() + if ($script:shellProcess.ExitCode -ne 0) { + throw "GraphCode Windows shell exited with code $($script:shellProcess.ExitCode)" + } + $script:shellProcess.Dispose() + $script:shellProcess = $null +} + +function Get-ProcessTreeIds([int[]] $roots) { + $all = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $ids = [Collections.Generic.HashSet[int]]::new() + foreach ($root in $roots) { [void] $ids.Add($root) } + $changed = $true + while ($changed) { + $changed = $false + foreach ($process in $all) { + if (-not $ids.Contains([int]$process.ProcessId) -and + $ids.Contains([int]$process.ParentProcessId)) { + [void] $ids.Add([int]$process.ProcessId) + $changed = $true + } + } + } + return @($ids) +} + +function Assert-PinnedCleanWorktree( + [string] $root, + [string] $expectedSha, + [string] $label +) { + if (-not (Test-Path -LiteralPath (Join-Path $root ".git"))) { + throw "$label provider root is not a Git worktree: $root" + } + $status = @(git -C $root status --porcelain --untracked-files=all) + if ($LASTEXITCODE -ne 0) { + throw "$label provider status failed" + } + if ($status.Count -ne 0) { + throw "$label provider worktree is dirty" + } + Assert-Equal (git -C $root rev-parse HEAD) $expectedSha "$label pin" +} + +function Assert-NoOrphanShellProcesses { + $processes = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and (Test-TestSessionProcess $_) + } + ) + if ($processes.Count -ne 0) { + throw "Windows shell cleanup left orphan processes: $($processes.ProcessId -join ', ')" + } +} + +Assert-PinnedCleanWorktree $WinghosttyRoot $pins.winghostty.sha "Winghostty" +Assert-PinnedCleanWorktree $ZmxRoot $pins.zmx.sha "zmx" + +try { + if (-not $SkipBuild) { + Invoke-Native "Winghostty host artifact" { + Push-Location $WinghosttyRoot + try { & $Zig0152 build -Demit-win32-host=true } finally { Pop-Location } + } + Invoke-Native "zmx Windows provider artifact" { + Push-Location $ZmxRoot + try { & $Zig0160 build -Dtarget=x86_64-windows-gnu } finally { Pop-Location } + } + Invoke-Native "GraphCode Windows shell" { + Push-Location $shellRoot + try { + & $Zig0152 build ` + "-Dwinghostty-dir=$WinghosttyRoot" ` + "-Dwinghostty-lib=$(Join-Path $WinghosttyRoot 'zig-out\lib\winghostty-win32-host.lib')" ` + "-Dversion=$Version" ` + -Doptimize=ReleaseSafe + } finally { Pop-Location } + } + } + + if (-not (Test-Path -LiteralPath $app)) { + throw "GraphCode Windows shell executable is missing: $app" + } + $reportedVersion = (& $app --version 2>$null | Select-Object -First 1).Trim() + if ($reportedVersion -ne $Version) { + throw "GraphCode Windows shell version mismatch: expected $Version, executable reports $reportedVersion" + } + $env:GRAPHCODE_ZMX = Join-Path $ZmxRoot "zig-out\bin\zmx.exe" + $env:GRAPHCODE_GATE_CWD = $repoRoot + $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = "1" + $env:GRAPHCODE_WORKSPACE_LAYOUT = $workspaceLayoutBase + $env:GRAPHCODE_SHELL_SESSION_PREFIX = $sessionPrefix + if ($UseStubDaemon) { + $env:GRAPHCODE_STUB_NODE_A = $testSessionIds[0] + $env:GRAPHCODE_STUB_NODE_B = $testSessionIds[1] + Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue + $pipeName = "graphcode-shell-stub-$PID" + $stubScript = Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1" + $stubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + "-NoProfile", + "-File", + $stubScript, + "-PipeName", + $pipeName, + "-ResultPath", + $stubResult + ) + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$pipeName" + $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = "1" + } + $arguments = @("--smoke") + if ($Stress) { $arguments += "--stress" } + Invoke-Native "GraphCode Windows shell smoke/stress" { + Invoke-ShellProcess $arguments "windows-shell:topology" + } + Record-TestOwnedSessions + Write-OwnedResourceMetrics "windows-shell:topology" + if ($UseStubDaemon -and -not $SkipTrayLive) { + Invoke-Native "GraphCode Windows tray live executable interaction" { + & (Join-Path $repoRoot "Tools\windows\Tests\TrayLive.Tests.ps1") ` + -Executable $app ` + -PipeName $pipeName ` + -ExternalDaemonPid $stubProcess.Id + } + } elseif ($UseStubDaemon) { + Write-Host "Skipping physical tray interaction because this runner has no interactive Explorer desktop." + } + if ($UseStubDaemon) { + Invoke-Native "GraphCode Windows shell restart smoke" { + Invoke-ShellProcess $arguments "windows-shell:restart" + } + Record-TestOwnedSessions + } + if ($UseStubDaemon) { + if (-not (Test-Path -LiteralPath $stubResult)) { + throw "Stub daemon did not write protocol evidence" + } + $evidence = Get-Content -LiteralPath $stubResult -Raw | ConvertFrom-Json + foreach ($property in @( + "protocolConnected", + "correlatedRequests", + "subscriptionSeen", + "reconnectObserved", + "graphSent" + )) { + if (-not [bool] $evidence.$property) { + throw "Stub daemon evidence failed: $property" + } + } + if ($evidence.error) { + throw "Stub daemon reported an error: $($evidence.error)" + } + foreach ($command in @("listRecentProjects", "openProject", "graphCommand")) { + if (@($evidence.commands) -notcontains $command) { + throw "Stub daemon did not observe command: $command" + } + } + Remove-Item -LiteralPath $busyResult,$busyError -Force -ErrorAction SilentlyContinue + $busyPipeName = "graphcode-shell-busy-$PID" + $busyStubProcess = Start-Process -FilePath "pwsh" -WindowStyle Hidden -PassThru -ArgumentList @( + "-NoProfile", + "-File", + $stubScript, + "-PipeName", + $busyPipeName, + "-ResultPath", + $busyResult, + "-NonReading" + ) + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$busyPipeName" + $env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR = "1" + $busyApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` + -RedirectStandardError $busyError + [void] $busyApp.Handle + if (-not $busyApp.WaitForExit(8000)) { + Stop-Process -Id $busyApp.Id -Force + throw "Busy daemon smoke blocked the UI beyond the bounded timeout" + } + $busyApp.WaitForExit() + $busyApp.Refresh() + Record-TestOwnedSessions + $busyEvidence = Get-Content -LiteralPath $busyResult -Raw | ConvertFrom-Json + if (-not [bool] $busyEvidence.busyObserved) { + throw "Busy daemon smoke did not accept a non-reading connection" + } + $busyErrorText = Get-Content -LiteralPath $busyError -Raw + if ($busyErrorText -notmatch "(?i)Smoke daemon status: .*daemon") { + throw "Busy daemon smoke did not post a daemon transport error" + } + $env:GRAPHCODE_DAEMON_PIPE = "\\.\pipe\$pipeName" + Remove-Item Env:GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR -ErrorAction SilentlyContinue + Remove-Item Env:GRAPHCODE_SHELL_NONREADING_ATTACH,Env:GRAPHCODE_SHELL_LARGE_PASTE ` + -ErrorAction SilentlyContinue + $env:GRAPHCODE_SHELL_NONREADING_ATTACH = "1" + $env:GRAPHCODE_SHELL_LARGE_PASTE = "1" + Remove-Item -LiteralPath $inputError -Force -ErrorAction SilentlyContinue + $inputApp = Start-Process -FilePath $app -ArgumentList @("--smoke") -PassThru ` + -RedirectStandardError $inputError + [void] $inputApp.Handle + [void] $ownedProcessIds.Add($inputApp.Id) + $shellProcess = $inputApp + Start-Sleep -Milliseconds 250 + Record-TestOwnedSessions + Write-OwnedResourceMetrics "windows-shell:large-paste" @($inputApp.Id) + if (-not $inputApp.WaitForExit(8000)) { + Stop-Process -Id $inputApp.Id -Force + throw "Large paste/non-reading attach smoke blocked the UI beyond the bounded timeout" + } + $inputApp.WaitForExit() + $inputApp.Refresh() + $inputExitCode = $inputApp.ExitCode + $inputApp.Dispose() + $shellProcess = $null + Record-TestOwnedSessions + if ($null -eq $inputExitCode) { + throw "Large paste/non-reading attach smoke completed without an observable exit code" + } + if ($inputExitCode -ne 0) { + $inputErrorText = if (Test-Path -LiteralPath $inputError) { + Get-Content -LiteralPath $inputError -Raw + } else { + "" + } + throw "Large paste/non-reading attach smoke failed with exit code $inputExitCode`: $inputErrorText" + } + Remove-Item -LiteralPath $inputError -Force -ErrorAction SilentlyContinue + } + if ($DaemonRuntimeDirectory) { + $runtime = Resolve-Path -LiteralPath $DaemonRuntimeDirectory -ErrorAction Stop + foreach ($name in @("graphcoded.exe", "graphcode.exe")) { + if (-not (Test-Path -LiteralPath (Join-Path $runtime $name) -PathType Leaf)) { + throw "Daemon handoff runtime is missing $name" + } + } + $handoffStage = Join-Path $shellRoot "daemon-handoff-live-$PID" + New-Item -ItemType Directory -Force -Path $handoffStage | Out-Null + Copy-Item -LiteralPath $app -Destination (Join-Path $handoffStage "graphcode-windows.exe") -Force + Get-ChildItem -LiteralPath $runtime -File | + Where-Object { $_.Name -in @("graphcoded.exe", "graphcode.exe") -or $_.Extension -ieq ".dll" } | + Copy-Item -Destination $handoffStage -Force + Invoke-Native "Concurrent shell daemon handoff" { + & (Join-Path $repoRoot "Tools\windows\Tests\DaemonHandoff.Live.Tests.ps1") ` + -Executable (Join-Path $handoffStage "graphcode-windows.exe") + } + } + Record-TestOwnedSessions + Write-Host "Windows shell smoke/stress: PASS" +} +finally { + if ($stubProcess -and -not $stubProcess.HasExited) { + Stop-Process -Id $stubProcess.Id -Force + } + if ($busyStubProcess -and -not $busyStubProcess.HasExited) { + Stop-Process -Id $busyStubProcess.Id -Force + } + if ($env:GRAPHCODE_ZMX -and (Test-Path -LiteralPath $env:GRAPHCODE_ZMX)) { + $treeProcessIds = Get-ProcessTreeIds @($ownedProcessIds) + foreach ($processId in $treeProcessIds) { + [void] $ownedProcessIds.Add($processId) + } + foreach ($session in @($ownedSessionNames)) { + & $env:GRAPHCODE_ZMX kill --force $session *> $null + } + foreach ($processId in @($ownedProcessIds)) { + if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { + Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue + } + } + $orphanTestDaemons = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -match "(?i)^zmx(?:\.exe)?$" -and + (Test-TestSessionProcess $_) + } + ) + foreach ($process in $orphanTestDaemons) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + } + Remove-Item Env:GRAPHCODE_ZMX -ErrorAction SilentlyContinue + Remove-Item Env:GRAPHCODE_GATE_CWD -ErrorAction SilentlyContinue + if ($null -eq $oldSessionPrefix) { + Remove-Item Env:GRAPHCODE_SHELL_SESSION_PREFIX -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_SHELL_SESSION_PREFIX = $oldSessionPrefix + } + if ($null -eq $oldStubNodeA) { + Remove-Item Env:GRAPHCODE_STUB_NODE_A -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_STUB_NODE_A = $oldStubNodeA + } + if ($null -eq $oldStubNodeB) { + Remove-Item Env:GRAPHCODE_STUB_NODE_B -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_STUB_NODE_B = $oldStubNodeB + } + if ($null -eq $oldPipe) { + Remove-Item Env:GRAPHCODE_DAEMON_PIPE -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_DAEMON_PIPE = $oldPipe + } + if ($null -eq $oldRequireDaemon) { + Remove-Item Env:GRAPHCODE_SHELL_REQUIRE_DAEMON -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_SHELL_REQUIRE_DAEMON = $oldRequireDaemon + } + if ($null -eq $oldNonreadingAttach) { + Remove-Item Env:GRAPHCODE_SHELL_NONREADING_ATTACH -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_SHELL_NONREADING_ATTACH = $oldNonreadingAttach + } + if ($null -eq $oldLargePaste) { + Remove-Item Env:GRAPHCODE_SHELL_LARGE_PASTE -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_SHELL_LARGE_PASTE = $oldLargePaste + } + if ($null -eq $oldWorkspaceActions) { + Remove-Item Env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_SHELL_WORKSPACE_ACTIONS = $oldWorkspaceActions + } + if ($null -eq $oldWorkspaceLayout) { + Remove-Item Env:GRAPHCODE_WORKSPACE_LAYOUT -ErrorAction SilentlyContinue + } else { + $env:GRAPHCODE_WORKSPACE_LAYOUT = $oldWorkspaceLayout + } + $layoutStem = [IO.Path]::GetFileNameWithoutExtension($workspaceLayoutBase) + Get-ChildItem -LiteralPath $shellRoot -Filter "$layoutStem*.json" -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stubResult -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $busyResult,$busyError,$inputError -Force -ErrorAction SilentlyContinue + if ($handoffStage) { + Remove-Item -LiteralPath $handoffStage -Recurse -Force -ErrorAction SilentlyContinue + } + Assert-NoOrphanShellProcesses +} + +exit 0 diff --git a/Tools/windows/zmx-validation-logs/evidence.json b/Tools/windows/zmx-validation-logs/evidence.json new file mode 100644 index 00000000..5c66b0f3 --- /dev/null +++ b/Tools/windows/zmx-validation-logs/evidence.json @@ -0,0 +1,146 @@ +[ + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-graph-start" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-quick-create-open" + }, + { + "out": "name=graphcode-graph-1-334b4d0a420f424c907f771d59149060\tpid=53124\tclients=0\tcreated=1786959365\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\nname=graphcode-quickchat-1-58b8f823bcff4aaf9ea82429b28b8557\tpid=59772\tclients=0\tcreated=1786959368\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\n", + "exit": 0, + "err": null, + "name": "p1-enumerate" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-live-attach" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-graph-survival" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-delete" + }, + { + "out": null, + "exit": 1, + "err": null, + "name": "p1-quick-gone" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p1-graph-cleanup" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-graph-start" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-quick-create-open" + }, + { + "out": "name=graphcode-graph-2-1a0d72c17d4b4c2186fecf54d58c1d5d\tpid=33888\tclients=0\tcreated=1786959382\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\nname=graphcode-quickchat-2-391e83e3ed874e0eac9d1347a423cda5\tpid=53064\tclients=0\tcreated=1786959384\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\n", + "exit": 0, + "err": null, + "name": "p2-enumerate" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-live-attach" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-graph-survival" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-delete" + }, + { + "out": null, + "exit": 1, + "err": null, + "name": "p2-quick-gone" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p2-graph-cleanup" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-graph-start" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-quick-create-open" + }, + { + "out": "name=graphcode-graph-3-809d0feae2e648eab68429d7568ad280\tpid=4932\tclients=0\tcreated=1786959398\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\nname=graphcode-quickchat-3-6b30b65a5edb4c61802963a1b84b8560\tpid=31800\tclients=0\tcreated=1786959400\tcwd=D:\\depot\\GraphCode-worktrees\\graphcode-win\tcmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20\n", + "exit": 0, + "err": null, + "name": "p3-enumerate" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-live-attach" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-graph-survival" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-delete" + }, + { + "out": null, + "exit": 1, + "err": null, + "name": "p3-quick-gone" + }, + { + "out": null, + "exit": 0, + "err": null, + "name": "p3-graph-cleanup" + } +] diff --git a/Tools/windows/zmx-validation-logs/p1-delete.err b/Tools/windows/zmx-validation-logs/p1-delete.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-delete.out b/Tools/windows/zmx-validation-logs/p1-delete.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-enumerate.err b/Tools/windows/zmx-validation-logs/p1-enumerate.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-enumerate.out b/Tools/windows/zmx-validation-logs/p1-enumerate.out new file mode 100644 index 00000000..3d94c697 --- /dev/null +++ b/Tools/windows/zmx-validation-logs/p1-enumerate.out @@ -0,0 +1,2 @@ +name=graphcode-graph-1-334b4d0a420f424c907f771d59149060 pid=53124 clients=0 created=1786959365 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 +name=graphcode-quickchat-1-58b8f823bcff4aaf9ea82429b28b8557 pid=59772 clients=0 created=1786959368 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 diff --git a/Tools/windows/zmx-validation-logs/p1-graph-cleanup.err b/Tools/windows/zmx-validation-logs/p1-graph-cleanup.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-graph-cleanup.out b/Tools/windows/zmx-validation-logs/p1-graph-cleanup.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-graph-start.err b/Tools/windows/zmx-validation-logs/p1-graph-start.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-graph-start.out b/Tools/windows/zmx-validation-logs/p1-graph-start.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-graph-survival.err b/Tools/windows/zmx-validation-logs/p1-graph-survival.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-graph-survival.out b/Tools/windows/zmx-validation-logs/p1-graph-survival.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-live-attach.err b/Tools/windows/zmx-validation-logs/p1-live-attach.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-live-attach.out b/Tools/windows/zmx-validation-logs/p1-live-attach.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-quick-create-open.err b/Tools/windows/zmx-validation-logs/p1-quick-create-open.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-quick-create-open.out b/Tools/windows/zmx-validation-logs/p1-quick-create-open.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-quick-gone.err b/Tools/windows/zmx-validation-logs/p1-quick-gone.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p1-quick-gone.out b/Tools/windows/zmx-validation-logs/p1-quick-gone.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-delete.err b/Tools/windows/zmx-validation-logs/p2-delete.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-delete.out b/Tools/windows/zmx-validation-logs/p2-delete.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-enumerate.err b/Tools/windows/zmx-validation-logs/p2-enumerate.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-enumerate.out b/Tools/windows/zmx-validation-logs/p2-enumerate.out new file mode 100644 index 00000000..e8dcbc03 --- /dev/null +++ b/Tools/windows/zmx-validation-logs/p2-enumerate.out @@ -0,0 +1,2 @@ +name=graphcode-graph-2-1a0d72c17d4b4c2186fecf54d58c1d5d pid=33888 clients=0 created=1786959382 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 +name=graphcode-quickchat-2-391e83e3ed874e0eac9d1347a423cda5 pid=53064 clients=0 created=1786959384 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 diff --git a/Tools/windows/zmx-validation-logs/p2-graph-cleanup.err b/Tools/windows/zmx-validation-logs/p2-graph-cleanup.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-graph-cleanup.out b/Tools/windows/zmx-validation-logs/p2-graph-cleanup.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-graph-start.err b/Tools/windows/zmx-validation-logs/p2-graph-start.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-graph-start.out b/Tools/windows/zmx-validation-logs/p2-graph-start.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-graph-survival.err b/Tools/windows/zmx-validation-logs/p2-graph-survival.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-graph-survival.out b/Tools/windows/zmx-validation-logs/p2-graph-survival.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-live-attach.err b/Tools/windows/zmx-validation-logs/p2-live-attach.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-live-attach.out b/Tools/windows/zmx-validation-logs/p2-live-attach.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-quick-create-open.err b/Tools/windows/zmx-validation-logs/p2-quick-create-open.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-quick-create-open.out b/Tools/windows/zmx-validation-logs/p2-quick-create-open.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-quick-gone.err b/Tools/windows/zmx-validation-logs/p2-quick-gone.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p2-quick-gone.out b/Tools/windows/zmx-validation-logs/p2-quick-gone.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-delete.err b/Tools/windows/zmx-validation-logs/p3-delete.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-delete.out b/Tools/windows/zmx-validation-logs/p3-delete.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-enumerate.err b/Tools/windows/zmx-validation-logs/p3-enumerate.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-enumerate.out b/Tools/windows/zmx-validation-logs/p3-enumerate.out new file mode 100644 index 00000000..2f0c4835 --- /dev/null +++ b/Tools/windows/zmx-validation-logs/p3-enumerate.out @@ -0,0 +1,2 @@ +name=graphcode-graph-3-809d0feae2e648eab68429d7568ad280 pid=4932 clients=0 created=1786959398 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 +name=graphcode-quickchat-3-6b30b65a5edb4c61802963a1b84b8560 pid=31800 clients=0 created=1786959400 cwd=D:\depot\GraphCode-worktrees\graphcode-win cmd=cmd/pwsh -NoProfile -Command Start-Sleep -Seconds 20 diff --git a/Tools/windows/zmx-validation-logs/p3-graph-cleanup.err b/Tools/windows/zmx-validation-logs/p3-graph-cleanup.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-graph-cleanup.out b/Tools/windows/zmx-validation-logs/p3-graph-cleanup.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-graph-start.err b/Tools/windows/zmx-validation-logs/p3-graph-start.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-graph-start.out b/Tools/windows/zmx-validation-logs/p3-graph-start.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-graph-survival.err b/Tools/windows/zmx-validation-logs/p3-graph-survival.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-graph-survival.out b/Tools/windows/zmx-validation-logs/p3-graph-survival.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-live-attach.err b/Tools/windows/zmx-validation-logs/p3-live-attach.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-live-attach.out b/Tools/windows/zmx-validation-logs/p3-live-attach.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-quick-create-open.err b/Tools/windows/zmx-validation-logs/p3-quick-create-open.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-quick-create-open.out b/Tools/windows/zmx-validation-logs/p3-quick-create-open.out new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-quick-gone.err b/Tools/windows/zmx-validation-logs/p3-quick-gone.err new file mode 100644 index 00000000..e69de29b diff --git a/Tools/windows/zmx-validation-logs/p3-quick-gone.out b/Tools/windows/zmx-validation-logs/p3-quick-gone.out new file mode 100644 index 00000000..e69de29b diff --git a/docs/quick-chats-daemon-protocol-gap.md b/docs/quick-chats-daemon-protocol-gap.md new file mode 100644 index 00000000..d17663cf --- /dev/null +++ b/docs/quick-chats-daemon-protocol-gap.md @@ -0,0 +1,45 @@ +# Quick Chats daemon protocol parity + +The former protocol gap is implemented in this branch. Quick Chats now use daemon-owned +Codable commands/events, persisted stable UUIDs, activity sequencing, v1/v2 event delivery, +and Windows wire/client APIs. + +## Implemented contract + +The shared protocol includes: + +```swift +case listQuickChats +case createQuickChat(title: String, backend: CLISessionBackendKind) +case openQuickChat(id: UUID) +case renameQuickChat(id: UUID, title: String) +case deleteQuickChat(id: UUID) +``` + +Add these `DaemonEvent` cases: + +```swift +case quickChatsListed([QuickChat]) +case quickChatChanged(QuickChat) +case quickChatDeleted(UUID) +case quickChatActivity(id: UUID, activity: String?, presence: PresenceReading?) +``` + +Every mutation is echoed and broadcast, and stale activity sequences are ignored by clients. +The Windows app exposes production create/open/rename/delete callbacks and keyboard actions. + +## Follow-up lifecycle integration + +The daemon command surface and launcher integration are complete: open waits for a live +session, reconnect reattaches, and delete confirms termination before removing durable +state. Provider lifecycle validation remains a separately pinned Windows smoke concern. + +## Windows provider validation + +The accepted provider pin for Quick Chat lifecycle validation is zmx +`029e11d2b19162fb3bdf90c8270237d303b8bfb4`, sourced from +the public `coneilen/zmx` repository's `graphcode-quickchat-hang` branch. +Each isolated run must use a unique `ZMX_DIR` that the provider creates itself (do not +pre-create the root; the provider secures it and returns `AccessDenied` for inherited +roots), unique graph/chat session names, an eight-second command timeout, and recorded +cleanup. diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 020b79a8..0f31013d 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -22,7 +22,7 @@ enum ExitCode { /// `EX_TEMPFAIL`. The command went out but its outcome never came back. It may have been /// applied — `node create`, `node send` and `node memo` are not idempotent, so this is /// the one case a wrapper must not blindly retry. - static let ambiguous: Int32 = 75 + static let ambiguous: Int32 = DaemonSocketClient.ambiguousExitCode } func fail(_ message: String, code: Int32 = ExitCode.usage) -> Never { @@ -417,5 +417,16 @@ do { applied — check with `graphcode status` rather than re-running it. """, code: ExitCode.ambiguous) } catch { +#if os(Windows) + if DaemonSocketClient.isAmbiguousConnectionClose(error) { + // The Windows transport uses its own error type for the same ambiguous mid-exchange + // close that Unix reports through FramedMessageIO. + fail( + """ + graphcoded closed the connection before answering. The command may still have been \ + applied — check with `graphcode status` rather than re-running it. + """, code: ExitCode.ambiguous) + } +#endif fail("\(error)") } diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md new file mode 100644 index 00000000..433e0241 --- /dev/null +++ b/graphcode-windows/README.md @@ -0,0 +1,77 @@ +# GraphCode Windows shell + +This is the production Zig/Win32 shell scaffold. It owns the top-level `HWND`, +the single Win32 message loop, sidebar/project chrome, graph surface, workspace +layout, and focus policy. GraphcodeKit and `graphcoded` remain the only owners of +graph/session orchestration and business rules. + +The shell connects to the current-user GraphcodeKit Named Pipe using the v2 +length-prefixed JSON envelope and falls back to the v1 command/event frame only +when the daemon does not negotiate v2. Commands and events are encoded from the +fixtures in `fixtures/`, which mirror the GraphcodeKit Codable wire shapes. + +Each terminal surface is a real Winghostty surface under the GraphCode parent +window and attaches to the persistent zmx session for its selected node. Surface +destruction kills only the attach client; zmx owns the session and survives shell +restarts. The host contract is the accepted two-surface terminal-gate contract, +not a synthetic terminal proof. + +The graph surface also provides native Win32 create/edit forms for nodes and +edges, a settings dialog, context menus, and keyboard-accessible actions: +`Ctrl+N` creates a node, `Ctrl+E` edits the selected node, `Ctrl+J` advances +selection, and `Ctrl+,` opens settings. Mutations are sent as correlated v2 +daemon requests; daemon refusals remain visible as explicit status errors. + +The shell exposes a native File/Loop/Terminal/View/Help menu bar. Menu items +share the same application action router as keyboard shortcuts, and project +actions use the Windows `IFileOpenDialog` folder picker. The no-project state +also presents accessible native buttons for opening a folder or the global +overview; recent projects remain selectable in the sidebar. + +Parity actions are reachable without App-specific view coupling: `Ctrl+P` opens +the searchable jump/palette form, `Ctrl+Up`/`Ctrl+Down` navigate by stable +project/node identity, `Ctrl+Tab` advances attention, and `Ctrl+Shift+R`, +`Ctrl+Shift+P`, and `Ctrl+Shift+A` toggle the workspace rail, panel, and +activity settings. `Ctrl+Q` creates a daemon-owned Quick Chat; `Ctrl+Shift+Q` +renames the selected chat and `Ctrl+Shift+X` deletes it. + +## Build + +From a fresh checkout, bootstrap the exact Zig toolchains, Swift 6.3.3, and +detached public provider pins: + +```powershell +pwsh -NoProfile -File Tools\windows\bootstrap.ps1 +. .\.graphcode-tools\environment.ps1 +pwsh -NoProfile -File Tools\windows\validate.ps1 ` + -Task windows-shell ` + -SwiftExecutable $env:GRAPHCODE_SWIFT633 +``` + +`Tools\windows\validate.ps1 -Task windows-shell` performs pin, clean-worktree, +format, lifecycle-contract, real provider build, and native UI Automation live +event checks. This scaffold has +package metadata only; it intentionally does not create an installer. + +## Tray lifecycle coverage + +The shell registers a version-4 notification icon with a stable `HWND`/icon ID +identity, restores through the same callback path used by Explorer, and +re-registers after `TaskbarCreated`. `TrayLive.Tests.ps1` retains physical +`Shell_NotifyIconGetRect` discovery (including monitor and DPI checks), while +its test-only registered-message hook relays Open and context events back +through and observes the production notification callback. This avoids treating +injected screen coordinates as a reliable substitute for overflow-tray +activation. The context test locates the live popup and its actual `Exit` item, +verifies the menu label and command ID, and activates it with physical input +rather than injecting a command message. + +When another shell owns the named startup reservation, daemon supervision opens +it for synchronization and waits only for the bounded reservation interval. +The spawning shell retains that reservation until its child has acquired the +lifetime lock and published its named-pipe listener through a child-ready event. +The child recognizes this handoff and does not wait on the parent-held +reservation. On timeout or incomplete publication the parent terminates only +its own child; contenders then recheck the endpoint and lifetime lock. The live +handoff test starts two distinct shell instances, verifies exactly one daemon, +and verifies that only the owning shell shuts it down. diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig new file mode 100644 index 00000000..bcaef4b3 --- /dev/null +++ b/graphcode-windows/build.zig @@ -0,0 +1,82 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{ + .default_target = .{ + .cpu_arch = .x86_64, + .os_tag = .windows, + .abi = .msvc, + }, + }); + const optimize = b.standardOptimizeOption(.{}); + const package_version = b.option([]const u8, "version", "Packaged GraphCode release version") orelse "dev"; + + const winghostty_dir = b.option( + []const u8, + "winghostty-dir", + "Path to the exact pinned Winghostty provider worktree", + ) orelse { + const fail = b.addFail("pass -Dwinghostty-dir="); + b.getInstallStep().dependOn(&fail.step); + return; + }; + const winghostty_include = b.option( + []const u8, + "winghostty-include", + "Optional Winghostty include directory", + ) orelse b.pathJoin(&.{ winghostty_dir, "include" }); + const winghostty_lib = b.option( + []const u8, + "winghostty-lib", + "Optional Winghostty static host library", + ) orelse b.pathJoin(&.{ winghostty_dir, "zig-out", "lib", "winghostty-win32-host.lib" }); + + const module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + module.addIncludePath(.{ .cwd_relative = winghostty_include }); + const build_options = b.addOptions(); + build_options.addOption([]const u8, "version", package_version); + module.addOptions("build_options", build_options); + + const exe = b.addExecutable(.{ + .name = "graphcode-windows", + .root_module = module, + }); + exe.subsystem = .Windows; + exe.addCSourceFile(.{ + .file = b.path("src/FolderPicker.c"), + .flags = &.{ "-DUNICODE", "-D_UNICODE" }, + }); + exe.addCSourceFile(.{ + .file = b.path("src/AccessibilityProvider.cpp"), + .flags = &.{ "-Wno-unused-command-line-argument" }, + }); + exe.addObjectFile(.{ .cwd_relative = winghostty_lib }); + for ([_][]const u8{ + "user32", + "gdi32", + "opengl32", + "kernel32", + "imm32", + "oleaut32", + "ole32", + "oleaut32", + "uiautomationcore", + "shell32", + "advapi32", + "winhttp", + }) |library| { + exe.linkSystemLibrary(library); + } + b.installArtifact(exe); + + const run_step = b.step("run", "Run the GraphCode Windows shell"); + const run = b.addRunArtifact(exe); + run.step.dependOn(b.getInstallStep()); + if (b.args) |args| run.addArgs(args); + run_step.dependOn(&run.step); +} diff --git a/graphcode-windows/build.zig.zon b/graphcode-windows/build.zig.zon new file mode 100644 index 00000000..527a5bf6 --- /dev/null +++ b/graphcode-windows/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .graphcode_windows, + .version = "0.1.0", + .fingerprint = 0x27916f97fa58b402, + .minimum_zig_version = "0.15.2", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "fixtures", + "provider-pins.json", + "package-metadata.json", + "README.md", + }, +} diff --git a/graphcode-windows/fixtures/daemon-v1-list-projects.json b/graphcode-windows/fixtures/daemon-v1-list-projects.json new file mode 100644 index 00000000..0f978424 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v1-list-projects.json @@ -0,0 +1 @@ +{"listRecentProjects":{}} diff --git a/graphcode-windows/fixtures/daemon-v2-arm-composite.json b/graphcode-windows/fixtures/daemon-v2-arm-composite.json new file mode 100644 index 00000000..5271b275 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-arm-composite.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000018","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"armComposite":{"_0":"11111111-1111-4111-8111-111111111111"}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-attention-worktree.json b/graphcode-windows/fixtures/daemon-v2-attention-worktree.json new file mode 100644 index 00000000..3d11c689 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-attention-worktree.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":9,"event":{"graphChanged":{"id":"attention-worktree","project":{"path":"C:\\work\\graph","name":"Attention fixture","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Waiting for approval","loopType":"turnBased","state":"running","activity":"needs input","presence":{"presence":"awaitingInput","confidence":"reported"},"worktreeBinding":{"path":"C:\\work\\graph-review","branch":"review"}},{"id":"22222222-2222-4222-8222-222222222222","title":"Failed check","loopType":"goalBased","state":"failed","activity":"tests failed","presence":{"presence":"idle","confidence":"reported"},"worktreeBinding":null}],"edges":[]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-close-project.json b/graphcode-windows/fixtures/daemon-v2-close-project.json new file mode 100644 index 00000000..e4a46e85 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-close-project.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000010","command":{"closeProject":{"path":"C:\\work\\graph"}}} diff --git a/graphcode-windows/fixtures/daemon-v2-create-edge.json b/graphcode-windows/fixtures/daemon-v2-create-edge.json new file mode 100644 index 00000000..ca0a1728 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-create-edge.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000005","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"createEdge":{"from":"11111111-1111-4111-8111-111111111111","to":"22222222-2222-4222-8222-222222222222","spec":{"kind":"handoff","condition":"always","payloadTransform":{"none":{}},"cycleGuard":null,"spawnTargetProjectPath":null}}}}}} \ No newline at end of file diff --git a/graphcode-windows/fixtures/daemon-v2-create-node.json b/graphcode-windows/fixtures/daemon-v2-create-node.json new file mode 100644 index 00000000..23d50f45 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-create-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000002","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"createNode":{"_0":{"id":"11111111-1111-4111-8111-111111111111","title":"Windows shell node","loopType":"turnBased","checkDescription":null,"triggerPrompt":null,"firstInstruction":"Work on the requested Windows shell task.","pausesBeforeWritesOnly":false,"goal":null,"backend":"claudeCode","modelTier":null,"worktree":null,"subGraph":null,"createdBy":null}}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-delete-edge.json b/graphcode-windows/fixtures/daemon-v2-delete-edge.json new file mode 100644 index 00000000..790983c1 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-delete-edge.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000006","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"deleteEdge":{"_0":"33333333-3333-4333-8333-333333333333"}}}}} \ No newline at end of file diff --git a/graphcode-windows/fixtures/daemon-v2-delete-node.json b/graphcode-windows/fixtures/daemon-v2-delete-node.json new file mode 100644 index 00000000..a42a6827 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-delete-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000013","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"deleteNode":{"_0":"11111111-1111-4111-8111-111111111111"}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-delete-project-graph.json b/graphcode-windows/fixtures/daemon-v2-delete-project-graph.json new file mode 100644 index 00000000..c68b7a30 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-delete-project-graph.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000012","command":{"deleteProjectGraph":{"path":"C:\\work\\graph"}}} diff --git a/graphcode-windows/fixtures/daemon-v2-forget-project.json b/graphcode-windows/fixtures/daemon-v2-forget-project.json new file mode 100644 index 00000000..c1f7fc2c --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-forget-project.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000011","command":{"forgetProject":{"path":"C:\\work\\graph"}}} diff --git a/graphcode-windows/fixtures/daemon-v2-graph-attention.json b/graphcode-windows/fixtures/daemon-v2-graph-attention.json new file mode 100644 index 00000000..1e63c371 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-graph-attention.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":10,"event":{"graphChanged":{"id":"attention-graph","project":{"path":"graphcode://fixture/attention","name":"Attention graph","remote":false},"nodes":[{"id":"awaiting","title":"Awaiting input","loopType":"turnBased","state":"running","activity":"paused at prompt","presence":{"presence":"awaitingInput","confidence":"reported"}},{"id":"failed","title":"Failed source","loopType":"goalBased","state":"failed","activity":"","presence":{"presence":"idle","confidence":"reported"}},{"id":"blocked","title":"Stranded target","loopType":"turnBased","state":"blocked","activity":"","presence":{"presence":"idle","confidence":"reported"}}],"edges":[{"from":"failed","to":"blocked","kind":"handoff","fired":false}]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-graph-event.json b/graphcode-windows/fixtures/daemon-v2-graph-event.json new file mode 100644 index 00000000..66de02c0 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-graph-event.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"00000000-0000-4000-8000-000000000001","project":{"id":"project-local","name":"Windows visual baseline","path":"graphcode://fixtures/windows-visual-baseline"},"nodes":[],"edges":[]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-graph-reordered-edges.json b/graphcode-windows/fixtures/daemon-v2-graph-reordered-edges.json new file mode 100644 index 00000000..79340679 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-graph-reordered-edges.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":9,"event":{"graphChanged":{"id":"reordered-graph","project":{"path":"graphcode://fixture/reordered","name":"Reordered graph"},"nodes":[{"id":"node-z","title":"Z","loopType":"turnBased","state":"idle","activity":"","presence":{"presence":"idle","confidence":"reported"}},{"id":"node-a","title":"A","loopType":"turnBased","state":"running","activity":"","presence":{"presence":"busy","confidence":"reported"}},{"id":"node-q","title":"Q","loopType":"turnBased","state":"blocked","activity":"","presence":{"presence":"idle","confidence":"reported"}}],"edges":[{"from":"node-a","to":"node-q"},{"from":"node-z","to":"node-a"}]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-hello.json b/graphcode-windows/fixtures/daemon-v2-hello.json new file mode 100644 index 00000000..60af2e87 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-hello.json @@ -0,0 +1 @@ +{"version":2,"kind":"hello","supportedVersions":[1,2],"clientID":"00000000-0000-4000-8000-000000000001"} diff --git a/graphcode-windows/fixtures/daemon-v2-list-projects.json b/graphcode-windows/fixtures/daemon-v2-list-projects.json new file mode 100644 index 00000000..a4435de7 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-list-projects.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000002","command":{"listRecentProjects":{}}} diff --git a/graphcode-windows/fixtures/daemon-v2-memo-node.json b/graphcode-windows/fixtures/daemon-v2-memo-node.json new file mode 100644 index 00000000..27137ac6 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-memo-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000016","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"memoNode":{"_0":"11111111-1111-4111-8111-111111111111","text":"learned","from":null}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-message-node.json b/graphcode-windows/fixtures/daemon-v2-message-node.json new file mode 100644 index 00000000..a703482a --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-message-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000003","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"messageNode":{"_0":"11111111-1111-4111-8111-111111111111","text":"hello","from":null}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-multi-project-remote.json b/graphcode-windows/fixtures/daemon-v2-multi-project-remote.json new file mode 100644 index 00000000..260609b2 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-multi-project-remote.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":13,"event":{"graphChanged":{"id":"graph-remote","project":{"path":"ssh://build/remote","name":"Remote Graph"},"nodes":[{"id":"remote-loop","title":"Remote loop","loopType":"goalBased","state":"failed","presence":{"presence":"idle"}}],"edges":[]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-multi-project.json b/graphcode-windows/fixtures/daemon-v2-multi-project.json new file mode 100644 index 00000000..eabc33a2 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-multi-project.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":12,"event":{"graphChanged":{"id":"graph-local","project":{"path":"C:\\work\\local","name":"Local Graph"},"nodes":[{"id":"local-loop","title":"Local loop","loopType":"turnBased","state":"running","presence":{"presence":"busy"}}],"edges":[]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-pilot-arm-refresh.json b/graphcode-windows/fixtures/daemon-v2-pilot-arm-refresh.json new file mode 100644 index 00000000..6e57bfae --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-pilot-arm-refresh.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000014","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"pilotComposite":{"_0":"11111111-1111-4111-8111-111111111111"}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-presence-event.json b/graphcode-windows/fixtures/daemon-v2-presence-event.json new file mode 100644 index 00000000..445284cb --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-presence-event.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"00000000-0000-4000-8000-000000000001","project":{"id":"project-local","name":"Windows visual baseline","path":"graphcode://fixtures/windows-visual-baseline"},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Metric gate","loopType":"goalBased","state":"running","activity":"editing fixture metrics","presence":{"presence":"busy","observedAt":"2026-01-15T14:55:00Z"}}],"edges":[]}}} diff --git a/graphcode-windows/fixtures/daemon-v2-quick-chat-mutation.json b/graphcode-windows/fixtures/daemon-v2-quick-chat-mutation.json new file mode 100644 index 00000000..a6c6e98b --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-quick-chat-mutation.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":42,"event":{"quickChatChanged":{"id":"11111111-1111-4111-8111-111111111111","title":"Renamed","backend":"claudeCode","createdAt":0,"activity":{"sequence":3,"text":"ready","presence":{"presence":"idle","confidence":"reported"}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-quick-chats.json b/graphcode-windows/fixtures/daemon-v2-quick-chats.json new file mode 100644 index 00000000..a1b6a177 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-quick-chats.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":41,"event":{"quickChatsListed":[{"id":"11111111-1111-4111-8111-111111111111","title":"Scratch","backend":"claudeCode","createdAt":0,"activity":{"sequence":2,"text":"editing","presence":{"presence":"busy","confidence":"reported"}}},{"id":"22222222-2222-4222-8222-222222222222","title":"Review","backend":"copilot","createdAt":1,"activity":null}]}} diff --git a/graphcode-windows/fixtures/daemon-v2-refresh-usage.json b/graphcode-windows/fixtures/daemon-v2-refresh-usage.json new file mode 100644 index 00000000..c333dd6b --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-refresh-usage.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000017","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"refreshUsage":{}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-stop-node.json b/graphcode-windows/fixtures/daemon-v2-stop-node.json new file mode 100644 index 00000000..66a74567 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-stop-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000004","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"stopNode":{"_0":"11111111-1111-4111-8111-111111111111"}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-subgraph-command.json b/graphcode-windows/fixtures/daemon-v2-subgraph-command.json new file mode 100644 index 00000000..5f001b62 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-subgraph-command.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000019","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"subGraphCommand":{"nodeID":"11111111-1111-4111-8111-111111111111","command":{"deleteNode":{"_0":"22222222-2222-4222-8222-222222222222"}}}}}}} diff --git a/graphcode-windows/fixtures/daemon-v2-subscribe.json b/graphcode-windows/fixtures/daemon-v2-subscribe.json new file mode 100644 index 00000000..51519181 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-subscribe.json @@ -0,0 +1 @@ +{"version":2,"kind":"hello","supportedVersions":[1,2],"clientID":"00000000-0000-4000-8000-000000000001","resumeFrom":0,"subscription":{"projectPaths":["graphcode://fixtures/windows-visual-baseline"]}} diff --git a/graphcode-windows/fixtures/daemon-v2-update-node.json b/graphcode-windows/fixtures/daemon-v2-update-node.json new file mode 100644 index 00000000..14d49e89 --- /dev/null +++ b/graphcode-windows/fixtures/daemon-v2-update-node.json @@ -0,0 +1 @@ +{"version":2,"kind":"request","requestID":"00000000-0000-4000-8000-000000000015","command":{"graphCommand":{"projectPath":"C:\\work\\graph","command":{"updateNode":{"_0":"11111111-1111-4111-8111-111111111111","update":{"goalSummary":"Done","goalPredicate":"test -f done","pollIntervalSeconds":30,"stallAfterSeconds":null,"metricCommand":null,"metricDirection":null,"triggerPrompt":null,"checkDescription":null,"modelTier":"fast","updatedBy":null}}}}}} diff --git a/graphcode-windows/fixtures/sidebar-recent-projects.json b/graphcode-windows/fixtures/sidebar-recent-projects.json new file mode 100644 index 00000000..a339a916 --- /dev/null +++ b/graphcode-windows/fixtures/sidebar-recent-projects.json @@ -0,0 +1 @@ +{"version":2,"kind":"event","sequence":4,"event":{"recentProjectsListed":[{"path":"C:\\work\\local","name":"Local Graph"},{"path":"ssh://build/remote","name":"Remote Graph"}]}} diff --git a/graphcode-windows/fixtures/swift-loopgraph-populated-invalid.json b/graphcode-windows/fixtures/swift-loopgraph-populated-invalid.json new file mode 100644 index 00000000..6a03db4e --- /dev/null +++ b/graphcode-windows/fixtures/swift-loopgraph-populated-invalid.json @@ -0,0 +1 @@ +{"id":"11111111-1111-4111-8111-111111111111","project":{"path":"C:\\work\\graph","name":"Graph","lastOpenedAt":1767225600},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Bad","loopType":"notARealLoop","pausesBeforeWritesOnly":false,"backend":"claudeCode","pilotState":"notPiloted","hasActiveDependents":false,"metricHistory":[],"state":"idle","createdAt":1767225600}],"edges":[]} diff --git a/graphcode-windows/fixtures/swift-loopgraph-populated-valid.json b/graphcode-windows/fixtures/swift-loopgraph-populated-valid.json new file mode 100644 index 00000000..cbcf0f07 --- /dev/null +++ b/graphcode-windows/fixtures/swift-loopgraph-populated-valid.json @@ -0,0 +1 @@ +{"edges":[{"condition":"onSuccess","cycleGuard":{"maxIterations":3,"stopAfterPassesWithoutImprovement":2,"until":"test -f done"},"fireCount":1,"from":"22222222-2222-4222-8222-222222222222","id":"66666666-6666-4666-8666-666666666666","kind":"handoff","payloadTransform":{"template":{"_0":"payload {{output}}"}},"spawnTargetProjectPath":"C:\\other","to":"22222222-2222-4222-8222-222222222222"}],"id":"11111111-1111-4111-8111-111111111111","nodes":[{"activity":"editing","backend":"claudeCode","checkDescription":"check","createdAt":788918400,"createdBy":"55555555-5555-4555-8555-555555555555","firstInstruction":"work","goal":{"metricCommand":"measure","metricDirection":"minimize","pollIntervalSeconds":30,"predicate":"test -f done","stallAfterSeconds":600,"summary":"finish"},"id":"22222222-2222-4222-8222-222222222222","loopType":"goalBased","metricHistory":[{"recordedAt":788918400,"value":1.5}],"modelTier":"capable","pausesBeforeWritesOnly":true,"pilotState":"armed","presence":{"confidence":"reported","presence":"busy"},"state":{"running":{}},"subGraph":{"edges":[],"id":"33333333-3333-4333-8333-333333333333","nodes":[{"backend":"copilotCLI","createdAt":788918400,"firstInstruction":"nested work","id":"44444444-4444-4444-8444-444444444444","loopType":"proactive","metricHistory":[],"pausesBeforeWritesOnly":false,"pilotState":"notPiloted","state":{"idle":{}},"title":"Nested loop"}],"project":{"lastOpenedAt":808633331.8823547,"name":"Nested","path":"C:\\work\\nested"}},"title":"Goal","triggerPrompt":"trigger","usage":{"costUSD":0.12,"inputTokens":12,"outputTokens":34,"reportedAt":788918400},"worktreeBinding":{"branch":"feature","id":"wt-1","repositoryPath":"C:\\repo","worktreePath":"C:\\repo-wt"}}],"project":{"lastOpenedAt":808633331.88241,"name":"Graph","path":"C:\\work\\graph"}} \ No newline at end of file diff --git a/graphcode-windows/fixtures/swift-loopgraph-valid.json b/graphcode-windows/fixtures/swift-loopgraph-valid.json new file mode 100644 index 00000000..49982472 --- /dev/null +++ b/graphcode-windows/fixtures/swift-loopgraph-valid.json @@ -0,0 +1 @@ +{"id":"11111111-1111-4111-8111-111111111111","project":{"path":"C:\\work\\graph","name":"graph","lastOpenedAt":1767225600},"nodes":[],"edges":[]} diff --git a/graphcode-windows/fixtures/swift-node-draft-valid.json b/graphcode-windows/fixtures/swift-node-draft-valid.json new file mode 100644 index 00000000..ed0132cd --- /dev/null +++ b/graphcode-windows/fixtures/swift-node-draft-valid.json @@ -0,0 +1 @@ +{"id":"22222222-2222-4222-8222-222222222222","title":"Loop","loopType":"turnBased","checkDescription":"check","triggerPrompt":null,"firstInstruction":"work","pausesBeforeWritesOnly":false,"goal":null,"backend":null,"modelTier":null,"worktree":null,"subGraph":null,"createdBy":null} diff --git a/graphcode-windows/fixtures/zmx-quick-chat-provider.json b/graphcode-windows/fixtures/zmx-quick-chat-provider.json new file mode 100644 index 00000000..d04fa2b6 --- /dev/null +++ b/graphcode-windows/fixtures/zmx-quick-chat-provider.json @@ -0,0 +1,12 @@ +{ + "providerSha": "029e11d2b19162fb3bdf90c8270237d303b8bfb4", + "source": "D:\\depot\\zmx-worktrees\\quickchat-hang", + "executable": "D:\\depot\\zmx-worktrees\\quickchat-hang\\.zig-cache\\current-validation\\zmx.exe", + "windowsQuickChatArgv": [ + "--daemon", + "graphcode-", + "" + ], + "isolation": "ZMX_DIR is unique per pass and provider creates the root", + "timeoutSeconds": 8 +} diff --git a/graphcode-windows/package-metadata.json b/graphcode-windows/package-metadata.json new file mode 100644 index 00000000..326d62d4 --- /dev/null +++ b/graphcode-windows/package-metadata.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "product": "GraphCode Windows shell", + "executable": "graphcode-windows.exe", + "launchMode": "tray-shell", + "platform": "windows-x86_64", + "installer": true, + "bundle": { + "script": "Tools/windows/package.ps1", + "artifactPattern": "GraphCode--windows-x86_64.zip", + "unsignedLabel": "UNSIGNED (development artifact; not code signed)" + }, + "runtime": { + "daemon": "graphcoded.exe", + "daemonLifecycle": "shell-supervised-when-unavailable", + "terminal": "zmx.exe", + "host": "winghostty-win32-host.lib" + }, + "providerPins": "provider-pins.json", + "validation": "Tools/windows/validate.ps1 -Task windows-shell" +} diff --git a/graphcode-windows/provider-pins.json b/graphcode-windows/provider-pins.json new file mode 100644 index 00000000..1d2f8106 --- /dev/null +++ b/graphcode-windows/provider-pins.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "winghostty": { + "repository": "coneilen/winghostty", + "remoteUrl": "https://github.com/coneilen/winghostty.git", + "sha": "f5abc059e4ca58b376eb209313aca7784659c679", + "artifact": "zig-out/lib/winghostty-win32-host.lib", + "minimumZig": "0.15.2" + }, + "zmx": { + "repository": "coneilen/zmx", + "remoteUrl": "https://github.com/coneilen/zmx.git", + "sha": "029e11d2b19162fb3bdf90c8270237d303b8bfb4", + "artifact": "zig-out/bin/zmx.exe", + "minimumZig": "0.16.0" + }, + "localFallback": { + "enabled": false, + "remoteWorkflowBlocked": false, + "reason": "Exact provider commits are publicly available and bootstrapped by Tools/windows/bootstrap.ps1.", + "paths": [] + } +} diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig new file mode 100644 index 00000000..1c97c3c9 --- /dev/null +++ b/graphcode-windows/src/Accessibility.zig @@ -0,0 +1,331 @@ +const std = @import("std"); +const WorktreeStatus = @import("WorktreeStatus.zig"); +const builtin = @import("builtin"); +const c = if (builtin.os.tag == .windows and builtin.link_libc) @import("Win32.zig").c else struct { + pub const HWND = ?*anyopaque; + pub const HANDLE = ?*anyopaque; + pub const WPARAM = usize; + pub const LPARAM = isize; + pub const LRESULT = isize; + pub const HRESULT = i32; + pub fn SetPropW(_: HWND, _: [*:0]const u16, _: HANDLE) c_int { return 0; } + pub fn RemovePropW(_: HWND, _: [*:0]const u16) HANDLE { return null; } + pub fn GetPropW(_: HWND, _: [*:0]const u16) HANDLE { return null; } + pub fn GetDesktopWindow() HWND { return null; } +}; +const provider_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.AccessibilityProvider"); +const NativeProvider = opaque {}; +extern fn gc_uia_create(hwnd: c.HWND) ?*NativeProvider; +extern fn gc_uia_release(provider: *NativeProvider) void; +extern fn gc_uia_get_object(hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM, provider: *NativeProvider) c.LRESULT; +extern fn gc_uia_set_status(provider: *NativeProvider, status: [*:0]const u8) c.HRESULT; +extern fn gc_uia_update( + provider: *NativeProvider, + status: [*:0]const u8, + identities: ?[*]const [*:0]const u8, + names: ?[*]const [*:0]const u8, + parents: ?[*]const c_int, + selected: ?[*]const c_int, + eligible: ?[*]const c_int, + invokable: ?[*]const c_int, + bounds: ?[*]const c_int, + count: c_int, + allow_reclaim: c_int, + confirm_each_reclaim: c_int, +) c.HRESULT; + +pub const Role = enum { window, navigation, list, list_item, button, card, menu, menu_item, text, terminal, status, dialog }; +pub const Pattern = enum { invoke, selection, selection_item, expand_collapse, scroll, value, text }; +pub const Element = struct { + id: []const u8, + name: []const u8, + role: Role, + patterns: []const Pattern = &.{}, + focusable: bool = false, + parent: ?usize = null, +}; +pub const NotificationKind = enum { status, @"error", focus, action }; +pub const Notification = struct { text: []const u8, kind: NotificationKind }; +pub const Announcement = struct { role: []const u8, name: []const u8, state: []const u8 }; +pub const WorktreeRow = struct { path: []const u8, selected: bool, eligible: bool }; +pub const DynamicElement = struct { + identity: []const u8, + name: []const u8, + parent: c_int, + selected: bool = false, + eligible: bool = false, + invokable: bool = true, + left: i32, + top: i32, + right: i32, + bottom: i32, +}; +pub const uia_selection_command_tag: usize = 0xC000000000000000; +pub const uia_selection_command_mask: usize = 0xC000000000000000; +pub const uia_selection_operation_mask: usize = 0x3000000000000000; +pub const uia_selection_operation_shift: u6 = 60; +pub const uia_row_payload_mask: usize = 0x0FFFFFFFFFFFFFFF; +pub const uia_open_overview_command: usize = 20; +pub const uia_open_quick_chats_command: usize = 21; +pub const uia_primary_canvas_action_command: usize = 22; +pub const uia_zoom_out_command: usize = 23; +pub const uia_actual_size_command: usize = 24; +pub const uia_zoom_in_command: usize = 25; +pub const uia_fit_command: usize = 26; +pub const uia_dynamic_invoke_tag: usize = 0x8000000000000000; +pub const uia_dynamic_invoke_mask: usize = 0xC000000000000000; + +pub fn worktreeIdentityPayload(path: []const u8) usize { + var hash: u64 = 1469598103934665603; + for (path) |value| { + hash ^= value; + hash *%= 1099511628211; + } + return @intCast(hash & uia_row_payload_mask); +} + +pub const Provider = struct { + allocator: std.mem.Allocator, + elements: std.array_list.Managed(Element), + focus_order: std.array_list.Managed(usize), + notifications: std.array_list.Managed(Notification), + attached_hwnd: c.HWND = null, + native_provider: ?*NativeProvider = null, + + pub fn init(allocator: std.mem.Allocator) Provider { + return .{ + .allocator = allocator, + .elements = std.array_list.Managed(Element).init(allocator), + .focus_order = std.array_list.Managed(usize).init(allocator), + .notifications = std.array_list.Managed(Notification).init(allocator), + }; + } + pub fn deinit(self: *Provider) void { + self.detach(); + self.elements.deinit(); self.focus_order.deinit(); self.notifications.deinit(); + } + pub fn attach(self: *Provider, hwnd: c.HWND) bool { + if (!builtin.link_libc) return false; + if (hwnd == null) return false; + const native = gc_uia_create(hwnd) orelse { + std.debug.print("UIA provider creation failed\n", .{}); + return false; + }; + if (c.SetPropW(hwnd, provider_property.ptr, @ptrCast(native)) == 0) { + std.debug.print("UIA SetPropW failed\n", .{}); + gc_uia_release(native); + return false; + } + self.attached_hwnd = hwnd; + self.native_provider = native; + return true; + } + pub fn detach(self: *Provider) void { + if (self.attached_hwnd) |hwnd| { + _ = c.RemovePropW(hwnd, provider_property.ptr); + self.attached_hwnd = null; + } + if (self.native_provider) |native| { + if (builtin.link_libc) gc_uia_release(native); + self.native_provider = null; + } + } + pub fn isAttached(self: *const Provider) bool { + const hwnd = self.attached_hwnd orelse return false; + const native = self.native_provider orelse return false; + return c.GetPropW(hwnd, provider_property.ptr) == @as(c.HANDLE, @ptrCast(native)); + } + pub fn getObject(self: *const Provider, hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM) c.LRESULT { + if (!builtin.link_libc) return 0; + const native = self.native_provider orelse return 0; + return gc_uia_get_object(hwnd, wparam, lparam, native); + } + pub fn syncWorktrees( + self: *Provider, + status: []const u8, + rows: []const WorktreeRow, + policy: WorktreeStatus.Policy, + ) void { + var elements = self.allocator.alloc(DynamicElement, rows.len) catch return; + defer self.allocator.free(elements); + for (rows, 0..) |row, index| { + const top = 34 + @as(i32, @intCast(index * 34)); + elements[index] = .{ + .identity = row.path, + .name = row.path, + .parent = 3, + .selected = row.selected, + .eligible = row.eligible, + .invokable = false, + .left = 12, + .top = top, + .right = 232, + .bottom = top + 32, + }; + } + self.syncElements(status, elements, policy); + } + pub fn syncElements( + self: *Provider, + status: []const u8, + elements: []const DynamicElement, + policy: WorktreeStatus.Policy, + ) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + const status_z = self.allocator.dupeZ(u8, status) catch return; + defer self.allocator.free(status_z); + var identities = self.allocator.alloc([*:0]const u8, elements.len) catch return; + defer self.allocator.free(identities); + var names = self.allocator.alloc([*:0]const u8, elements.len) catch return; + defer self.allocator.free(names); + var parents = self.allocator.alloc(c_int, elements.len) catch return; + defer self.allocator.free(parents); + var selected = self.allocator.alloc(c_int, elements.len) catch return; + defer self.allocator.free(selected); + var eligible = self.allocator.alloc(c_int, elements.len) catch return; + defer self.allocator.free(eligible); + var invokable = self.allocator.alloc(c_int, elements.len) catch return; + defer self.allocator.free(invokable); + var bounds = self.allocator.alloc(c_int, elements.len * 4) catch return; + defer self.allocator.free(bounds); + var owned_identities = self.allocator.alloc([:0]u8, elements.len) catch return; + defer self.allocator.free(owned_identities); + var owned_names = self.allocator.alloc([:0]u8, elements.len) catch return; + defer self.allocator.free(owned_names); + for (owned_identities) |*value| value.* = @constCast(&.{}); + for (owned_names) |*value| value.* = @constCast(&.{}); + defer for (owned_identities) |value| self.allocator.free(value); + defer for (owned_names) |value| self.allocator.free(value); + for (elements, 0..) |element, index| { + owned_identities[index] = self.allocator.dupeZ(u8, element.identity) catch return; + owned_names[index] = self.allocator.dupeZ(u8, element.name) catch return; + identities[index] = owned_identities[index].ptr; + names[index] = owned_names[index].ptr; + parents[index] = element.parent; + selected[index] = if (element.selected) 1 else 0; + eligible[index] = if (element.eligible) 1 else 0; + invokable[index] = if (element.invokable) 1 else 0; + bounds[index * 4] = element.left; + bounds[index * 4 + 1] = element.top; + bounds[index * 4 + 2] = element.right; + bounds[index * 4 + 3] = element.bottom; + } + _ = gc_uia_update( + native, + status_z.ptr, + if (identities.len == 0) null else identities.ptr, + if (names.len == 0) null else names.ptr, + if (parents.len == 0) null else parents.ptr, + if (selected.len == 0) null else selected.ptr, + if (eligible.len == 0) null else eligible.ptr, + if (invokable.len == 0) null else invokable.ptr, + if (bounds.len == 0) null else bounds.ptr, + @intCast(elements.len), + if (policy.allow_reclaim) 1 else 0, + if (policy.confirm_each_reclaim) 1 else 0, + ); + } + pub fn syncStatus(self: *Provider, status: []const u8) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + const status_z = self.allocator.dupeZ(u8, status) catch return; + defer self.allocator.free(status_z); + _ = gc_uia_set_status(native, status_z.ptr); + } + pub fn add(self: *Provider, element: Element) !usize { + const index = self.elements.items.len; + try self.elements.append(element); + if (element.focusable) try self.focus_order.append(index); + return index; + } + pub fn announce(self: *Provider, text: []const u8, kind: NotificationKind) !void { + try self.notifications.append(.{ .text = text, .kind = kind }); + } + pub fn nextFocus(self: *const Provider, current: ?usize) ?usize { + if (self.focus_order.items.len == 0) return null; + if (current) |value| for (self.focus_order.items, 0..) |index, offset| { + if (index == value) return self.focus_order.items[(offset + 1) % self.focus_order.items.len]; + }; + return self.focus_order.items[0]; + } + pub fn hasPattern(self: *const Provider, index: usize, pattern: Pattern) bool { + if (index >= self.elements.items.len) return false; + for (self.elements.items[index].patterns) |candidate| if (candidate == pattern) return true; + return false; + } +}; + +pub fn defaultContract(allocator: std.mem.Allocator) !Provider { + var provider = Provider.init(allocator); + errdefer provider.deinit(); + const window = try provider.add(.{ .id = "window", .name = "GraphCode Windows", .role = .window }); + const sidebar = try provider.add(.{ .id = "sidebar", .name = "Navigation", .role = .navigation, .parent = window }); + _ = try provider.add(.{ .id = "projects", .name = "Projects", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); + _ = try provider.add(.{ .id = "loops", .name = "Loops", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); + _ = try provider.add(.{ .id = "worktrees", .name = "Worktrees", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); + const graph = try provider.add(.{ .id = "graph", .name = "Graph", .role = .navigation, .parent = window }); + _ = try provider.add(.{ .id = "graph-card", .name = "Graph card", .role = .card, .parent = graph, .focusable = true, .patterns = &.{ .selection, .invoke } }); + _ = try provider.add(.{ .id = "overview-destination", .name = "Graph", .role = .button, .parent = sidebar, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "quick-chats-destination", .name = "Quick Chats", .role = .button, .parent = sidebar, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "canvas-primary-action", .name = "New Loop or Chat", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "zoom-out", .name = "Zoom out", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "actual-size", .name = "Actual size", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "zoom-in", .name = "Zoom in", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "fit-canvas", .name = "Fit canvas", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); + const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window, .focusable = true, .patterns = &.{ .expand_collapse } }); + _ = try provider.add(.{ .id = "inspect-worktrees", .name = "Inspect worktrees", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); + _ = try provider.add(.{ .id = "reclaim-worktrees", .name = "Reclaim selected worktrees", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); + _ = try provider.add(.{ .id = "reveal-worktree", .name = "Reveal in Explorer", .role = .menu_item, .parent = menu, .patterns = &.{ .invoke } }); + _ = try provider.add(.{ .id = "terminal-a", .name = "Terminal A", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); + _ = try provider.add(.{ .id = "terminal-b", .name = "Terminal B", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); + _ = try provider.add(.{ .id = "status", .name = "Status", .role = .status, .parent = window }); + _ = try provider.add(.{ .id = "errors", .name = "Errors", .role = .status, .parent = window }); + return provider; +} + +pub fn nodeAnnouncement(title: []const u8, state: []const u8) Announcement { + return .{ .role = "Graph node", .name = title, .state = state }; +} + +pub fn terminalAnnouncement(index: usize) Announcement { + return .{ + .role = "Terminal surface", + .name = if (index == 0) "GraphCode terminal A" else "GraphCode terminal B", + .state = "interactive", + }; +} + +pub fn log(announcement: Announcement) void { + _ = announcement; +} + +test "UIA contract exposes named roles patterns and deterministic focus order" { + var provider = try defaultContract(std.testing.allocator); + defer provider.deinit(); + try std.testing.expectEqual(Role.navigation, provider.elements.items[1].role); + try std.testing.expect(provider.hasPattern(2, .selection)); + try std.testing.expect(provider.hasPattern(11, .text)); + try std.testing.expectEqual(@as(?usize, 3), provider.nextFocus(2)); + try std.testing.expectEqual(@as(?usize, 4), provider.nextFocus(3)); +} + +test "status and error announcements are retained for screen readers" { + var provider = Provider.init(std.testing.allocator); + defer provider.deinit(); + try provider.announce("Worktrees inspected", .status); + try provider.announce("Reclaim blocked: unpushed commits", .@"error"); + try std.testing.expectEqual(@as(usize, 2), provider.notifications.items.len); +} + +test "production provider attaches to and detaches from a live HWND" { + if (!builtin.link_libc) return; + var provider = Provider.init(std.testing.allocator); + defer provider.deinit(); + const hwnd = c.GetDesktopWindow(); + try std.testing.expect(hwnd != null); + try std.testing.expect(provider.attach(hwnd)); + try std.testing.expect(provider.isAttached()); + provider.detach(); + try std.testing.expect(!provider.isAttached()); +} diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp new file mode 100644 index 00000000..e49f70f9 --- /dev/null +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -0,0 +1,960 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int64_t kRowPrefix = 0x1000000000000000LL; +constexpr uint64_t kRowPayloadMask = 0x0fffffffffffffffULL; +constexpr WPARAM kSelectionCommandTag = 0xc000000000000000ULL; +constexpr WPARAM kDynamicInvokeTag = 0x8000000000000000ULL; +constexpr WPARAM kSelectionOperationMask = 0x3000000000000000ULL; +constexpr int kSelectionOperationShift = 60; + +enum SelectionOperation { kSelect = 0, kAdd = 1, kRemove = 2 }; + +class Node; +struct Row { + std::string identity; + std::wstring name; + int parent = 3; + bool selected = false; + bool eligible = false; + bool invokable = false; + RECT bounds{}; +}; +struct State { + std::mutex mutex; + HWND hwnd{}; + Node *root{}; + std::unordered_map elements; + std::wstring status = L"Ready"; + std::unordered_map rows; + std::vector row_order; + int64_t focused = 0; + bool allow_reclaim = false; + bool confirm_each_reclaim = true; + bool active = true; +}; + +static std::wstring wide(const char *value) { + if (!value) return {}; + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, nullptr, 0); + if (length <= 0) return {}; + std::wstring result(static_cast(length), L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + const_cast(result.data()), length); + result.pop_back(); + return result; +} + +static uint64_t hashPath(const std::string &path) { + uint64_t hash = 1469598103934665603ULL; + for (unsigned char value : path) { + hash ^= value; + hash *= 1099511628211ULL; + } + return hash; +} + +static bool isRowKey(int64_t id) { + return (static_cast(id) & static_cast(kRowPrefix)) != 0; +} + +class Node final : public IRawElementProviderSimple, + public IRawElementProviderFragment, + public IRawElementProviderFragmentRoot, + public IInvokeProvider, + public ISelectionProvider, + public ISelectionItemProvider, + public IToggleProvider { + public: + Node(std::shared_ptr state, int64_t id) : state_(std::move(state)), id_(id), refs_(1) { + if (id_ == 0) { + std::lock_guard lock(state_->mutex); + state_->root = this; + } + } + ~Node() { + std::lock_guard lock(state_->mutex); + if (id_ == 0 && state_->root == this) state_->root = nullptr; + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, void **out) override { + if (!out) return E_POINTER; + *out = nullptr; + if (iid == IID_IUnknown || iid == __uuidof(IRawElementProviderSimple)) + *out = static_cast(this); + else if (iid == __uuidof(IRawElementProviderFragment)) + *out = static_cast(this); + else if (iid == __uuidof(IRawElementProviderFragmentRoot) && id_ == 0) + *out = static_cast(this); + else if (iid == __uuidof(IInvokeProvider) && supportsInvoke()) + *out = static_cast(this); + else if (iid == __uuidof(ISelectionProvider) && id_ >= 1 && id_ <= 4) + *out = static_cast(this); + else if (iid == __uuidof(ISelectionItemProvider) && isAvailableRow()) + *out = static_cast(this); + else if (iid == __uuidof(IToggleProvider) && (id_ == 12 || id_ == 13)) + *out = static_cast(this); + else + return E_NOINTERFACE; + AddRef(); + return S_OK; + } + + ULONG STDMETHODCALLTYPE AddRef() override { + return static_cast(InterlockedIncrement(&refs_)); + } + ULONG STDMETHODCALLTYPE Release() override { + ULONG value = static_cast(InterlockedDecrement(&refs_)); + if (value == 0) delete this; + return value; + } + + HRESULT STDMETHODCALLTYPE get_ProviderOptions(ProviderOptions *value) override { + if (!value) return E_POINTER; + *value = static_cast( + ProviderOptions_ServerSideProvider | ProviderOptions_UseComThreading | + ProviderOptions_ProviderOwnsSetFocus); + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetPatternProvider(PATTERNID id, IUnknown **value) override { + if (!value) return E_POINTER; + *value = nullptr; + if (id == UIA_InvokePatternId && supportsInvoke()) + *value = static_cast(this); + else if (id == UIA_SelectionPatternId && id_ >= 1 && id_ <= 4) + *value = static_cast(this); + else if (id == UIA_SelectionItemPatternId && isAvailableRow()) + *value = static_cast(this); + else if (id == UIA_TogglePatternId && (id_ == 12 || id_ == 13)) + *value = static_cast(this); + else + return S_FALSE; + AddRef(); + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetPropertyValue(PROPERTYID property, VARIANT *value) override { + if (!value) return E_POINTER; + VariantInit(value); + std::wstring string_value; + LONG integer_value = 0; + bool bool_value = false; + enum { kNone, kString, kInteger, kBool } kind = kNone; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (property == UIA_NamePropertyId) { + string_value = nameLocked(); + kind = kString; + } else if (property == UIA_AutomationIdPropertyId) { + string_value = automationIdLocked(); + kind = kString; + } else if (property == UIA_ControlTypePropertyId) { + integer_value = controlTypeLocked(); + kind = kInteger; + } else if (property == UIA_IsKeyboardFocusablePropertyId || + property == UIA_IsEnabledPropertyId || + property == UIA_IsControlElementPropertyId || + property == UIA_IsContentElementPropertyId) { + bool_value = true; + kind = kBool; + } else if (property == UIA_HasKeyboardFocusPropertyId) { + bool_value = state_->focused == id_; + kind = kBool; + } else if (property == UIA_LiveSettingPropertyId && id_ == 6) { + integer_value = 1; + kind = kInteger; + } else if (property == UIA_ValueValuePropertyId && id_ == 6) { + string_value = state_->status; + kind = kString; + } + } + if (kind == kNone) return S_FALSE; + if (kind == kString) { + value->vt = VT_BSTR; + value->bstrVal = SysAllocString(string_value.c_str()); + return value->bstrVal ? S_OK : E_OUTOFMEMORY; + } + if (kind == kInteger) { + value->vt = VT_I4; + value->lVal = integer_value; + return S_OK; + } + value->vt = VT_BOOL; + value->boolVal = bool_value ? VARIANT_TRUE : VARIANT_FALSE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_HostRawElementProvider(IRawElementProviderSimple **value) override { + if (!value) return E_POINTER; + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) { + *value = nullptr; + return UIA_E_ELEMENTNOTAVAILABLE; + } + if (id_ != 0) { + *value = nullptr; + return S_OK; + } + hwnd = state_->hwnd; + } + return UiaHostProviderFromHwnd(hwnd, value); + } + + HRESULT STDMETHODCALLTYPE Navigate(NavigateDirection direction, + IRawElementProviderFragment **value) override { + if (!value) return E_POINTER; + *value = nullptr; + Node *target = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + int64_t destination = -1; + if (direction == NavigateDirection_Parent) destination = parentLocked(); + if (direction == NavigateDirection_FirstChild) destination = firstChildLocked(); + if (direction == NavigateDirection_LastChild) destination = lastChildLocked(); + if (direction == NavigateDirection_NextSibling) destination = siblingLocked(1); + if (direction == NavigateDirection_PreviousSibling) destination = siblingLocked(-1); + if (destination >= 0) target = retainElementLocked(destination); + } + if (target) *value = static_cast(target); + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetRuntimeId(SAFEARRAY **value) override { + if (!value) return E_POINTER; + *value = nullptr; + int64_t id = 0; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (id_ == 0) return S_OK; + id = id_; + } + const LONG count = isRowKey(id) ? 4 : 2; + *value = SafeArrayCreateVector(VT_I4, 0, count); + if (!*value) return E_OUTOFMEMORY; + if (isRowKey(id)) { + const uint64_t row = static_cast(id); + LONG values[4] = { + UiaAppendRuntimeId, + 0x475243, + static_cast(row & 0xffffffffULL), + static_cast(row >> 32), + }; + for (LONG index = 0; index < count; ++index) SafeArrayPutElement(*value, &index, &values[index]); + } else { + LONG values[2] = {UiaAppendRuntimeId, static_cast(id)}; + for (LONG index = 0; index < count; ++index) SafeArrayPutElement(*value, &index, &values[index]); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_BoundingRectangle(UiaRect *value) override { + if (!value) return E_POINTER; + HWND hwnd = nullptr; + RECT dynamic_bounds{}; + bool has_dynamic_bounds = false; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + hwnd = state_->hwnd; + if (isRowKey(id_)) { + dynamic_bounds = state_->rows.at(id_).bounds; + has_dynamic_bounds = true; + } + } + RECT rect{}; + GetClientRect(hwnd, &rect); + POINT origin{0, 0}; + ClientToScreen(hwnd, &origin); + value->left = origin.x; + value->top = origin.y; + value->width = rect.right - rect.left; + value->height = 28; + if (has_dynamic_bounds) { + value->left = origin.x + dynamic_bounds.left; + value->top = origin.y + dynamic_bounds.top; + value->width = dynamic_bounds.right - dynamic_bounds.left; + value->height = dynamic_bounds.bottom - dynamic_bounds.top; + } else if (id_ == 14 || id_ == 15) { + value->left = origin.x + 8; + value->top = origin.y + (id_ == 14 ? 142 : 358); + value->width = 220; + value->height = 24; + } else if (id_ == 16) { + value->left = origin.x + (rect.right > 140 ? rect.right - 140 : 0); + value->top = origin.y + 56; + value->width = 120; + value->height = 32; + } else if (id_ >= 17 && id_ <= 20) { + const LONG widths[] = {36, 68, 36, 38}; + LONG left = rect.right > 190 ? rect.right - 190 : 0; + for (int64_t index = 17; index < id_; ++index) left += widths[index - 17]; + value->left = origin.x + left; + value->top = origin.y + (rect.bottom > 48 ? rect.bottom - 48 : 0); + value->width = widths[id_ - 17]; + value->height = 36; + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetEmbeddedFragmentRoots(SAFEARRAY **value) override { + if (!value) return E_POINTER; + *value = nullptr; + return S_OK; + } + HRESULT STDMETHODCALLTYPE SetFocus() override { + bool changed = false; + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + changed = state_->focused != id_; + state_->focused = id_; + hwnd = state_->hwnd; + } + if (hwnd) { + const DWORD current_thread = GetCurrentThreadId(); + const DWORD window_thread = GetWindowThreadProcessId(hwnd, nullptr); + const bool attach = + window_thread != 0 && window_thread != current_thread; + if (attach && !AttachThreadInput(current_thread, window_thread, TRUE)) + return HRESULT_FROM_WIN32(GetLastError()); + SetForegroundWindow(hwnd); + SetLastError(ERROR_SUCCESS); + if (::SetFocus(hwnd) == nullptr) { + const DWORD error = GetLastError(); + if (error != ERROR_SUCCESS) { + if (attach) AttachThreadInput(current_thread, window_thread, FALSE); + return HRESULT_FROM_WIN32(error); + } + } + if (attach) AttachThreadInput(current_thread, window_thread, FALSE); + } + if (changed) { + UiaRaiseAutomationEvent( + static_cast(this), + UIA_AutomationFocusChangedEventId); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_FragmentRoot(IRawElementProviderFragmentRoot **value) override { + if (!value) return E_POINTER; + *value = nullptr; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || !state_->root) return UIA_E_ELEMENTNOTAVAILABLE; + state_->root->AddRef(); + *value = static_cast(state_->root); + return S_OK; + } + HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double, double, + IRawElementProviderFragment **value) override { + return focusedElement(value); + } + HRESULT STDMETHODCALLTYPE GetFocus(IRawElementProviderFragment **value) override { + return focusedElement(value); + } + + HRESULT STDMETHODCALLTYPE Invoke() override { + if (!supportsInvoke()) return UIA_E_ELEMENTNOTENABLED; + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + hwnd = state_->hwnd; + } + if (isRowKey(id_)) { + const WPARAM command = kDynamicInvokeTag | + (static_cast(id_) & kRowPayloadMask); + PostMessageW(hwnd, WM_COMMAND, command, 0); + return S_OK; + } + const UINT command = id_ == 7 ? 6 : id_ == 8 ? 7 : id_ == 9 ? 8 : + id_ == 10 ? 9 : id_ == 11 ? 10 : id_ == 12 ? 12 : + id_ == 13 ? 13 : id_ == 14 ? 20 : id_ == 15 ? 21 : + id_ == 16 ? 22 : id_ == 17 ? 23 : id_ == 18 ? 24 : + id_ == 19 ? 25 : 26; + PostMessageW(hwnd, WM_COMMAND, command, 0); + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_CanSelectMultiple(BOOL *value) override { + if (!value) return E_POINTER; + *value = TRUE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_IsSelectionRequired(BOOL *value) override { + if (!value) return E_POINTER; + *value = FALSE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetSelection(SAFEARRAY **value) override { + if (!value) return E_POINTER; + std::vector selected; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || id_ < 1 || id_ > 4) { + *value = nullptr; + return UIA_E_ELEMENTNOTAVAILABLE; + } + for (int64_t id : state_->row_order) { + const auto row = state_->rows.find(id); + if (row != state_->rows.end() && row->second.parent == id_ && row->second.selected) { + if (Node *node = retainElementLocked(id)) selected.push_back(node); + } + } + } + *value = SafeArrayCreateVector(VT_UNKNOWN, 0, static_cast(selected.size())); + if (!*value) { + for (Node *node : selected) node->Release(); + return selected.empty() ? S_OK : E_OUTOFMEMORY; + } + for (LONG index = 0; index < static_cast(selected.size()); ++index) { + IUnknown *unknown = static_cast(selected[static_cast(index)]); + SafeArrayPutElement(*value, &index, unknown); + selected[static_cast(index)]->Release(); + } + return S_OK; + } + HRESULT STDMETHODCALLTYPE Select() override { return setSelected(kSelect); } + HRESULT STDMETHODCALLTYPE AddToSelection() override { return setSelected(kAdd); } + HRESULT STDMETHODCALLTYPE RemoveFromSelection() override { return setSelected(kRemove); } + HRESULT STDMETHODCALLTYPE get_IsSelected(BOOL *value) override { + if (!value) return E_POINTER; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || !isRowKey(id_)) + return UIA_E_ELEMENTNOTAVAILABLE; + *value = state_->rows.at(id_).selected ? TRUE : FALSE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_SelectionContainer(IRawElementProviderSimple **value) override { + if (!value) return E_POINTER; + *value = nullptr; + Node *container = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || !isRowKey(id_)) + return UIA_E_ELEMENTNOTAVAILABLE; + container = retainElementLocked(state_->rows.at(id_).parent); + } + if (!container) return UIA_E_ELEMENTNOTAVAILABLE; + *value = static_cast(container); + return S_OK; + } + HRESULT STDMETHODCALLTYPE Toggle() override { + if (id_ != 12 && id_ != 13) return UIA_E_INVALIDOPERATION; + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + hwnd = state_->hwnd; + } + PostMessageW(hwnd, WM_COMMAND, static_cast(id_), 0); + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_ToggleState(ToggleState *value) override { + if (!value) return E_POINTER; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (id_ == 12) *value = state_->allow_reclaim ? ToggleState_On : ToggleState_Off; + else if (id_ == 13) *value = state_->confirm_each_reclaim ? ToggleState_On : ToggleState_Off; + else return UIA_E_INVALIDOPERATION; + return S_OK; + } + + void update(const char *status, const char **identities, const char **names, + const int *parents, const int *selected, const int *eligible, + const int *invokable, const int *bounds, int count, bool allow_reclaim, + bool confirm_each_reclaim) { + std::wstring old_status; + std::wstring new_status; + Node *status_node = nullptr; + Node *focus_node = nullptr; + Node *allow_reclaim_node = nullptr; + Node *confirm_reclaim_node = nullptr; + std::vector retired; + std::vector> selection_events; + bool status_changed = false; + bool focus_changed = false; + bool structure_changed = false; + bool allow_reclaim_changed = false; + bool confirm_reclaim_changed = false; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + old_status = state_->status; + state_->status = wide(status); + new_status = state_->status; + status_changed = old_status != new_status; + const bool old_allow_reclaim = state_->allow_reclaim; + const bool old_confirm_reclaim = state_->confirm_each_reclaim; + state_->allow_reclaim = allow_reclaim; + state_->confirm_each_reclaim = confirm_each_reclaim; + + std::unordered_map next_rows; + std::vector next_order; + for (int index = 0; index < count; ++index) { + const std::string identity = identities && identities[index] ? identities[index] : ""; + int64_t key = rowKeyForIdentityLocked(identity, next_rows); + const auto existing = std::find_if( + state_->rows.begin(), state_->rows.end(), + [&identity](const std::pair &item) { + return item.second.identity == identity; + }); + if (existing != state_->rows.end()) key = existing->first; + while (next_rows.find(key) != next_rows.end()) key = nextRowKey(key); + next_rows.emplace(key, Row{ + identity, + wide(names && names[index] ? names[index] : identity.c_str()), + parents ? parents[index] : 3, + selected && selected[index] != 0, + eligible && eligible[index] != 0, + invokable && invokable[index] != 0, + bounds ? RECT{bounds[index * 4], bounds[index * 4 + 1], + bounds[index * 4 + 2], bounds[index * 4 + 3]} : RECT{}, + }); + next_order.push_back(key); + } + structure_changed = state_->row_order != next_order || state_->rows.size() != next_rows.size(); + std::vector added_selection; + std::vector removed_selection; + size_t next_selected_count[5]{}; + for (const auto &item : next_rows) { + if (item.second.parent < 1 || item.second.parent > 4) continue; + if (item.second.selected) ++next_selected_count[item.second.parent]; + const auto old = state_->rows.find(item.first); + if (item.second.selected && + (old == state_->rows.end() || !old->second.selected)) { + added_selection.push_back(item.first); + } + } + for (const auto &item : state_->rows) { + if (item.second.parent < 1 || item.second.parent > 4) continue; + const auto next = next_rows.find(item.first); + if (item.second.selected && + (next == next_rows.end() || !next->second.selected)) { + removed_selection.push_back(item.first); + } + } + for (const auto &item : state_->rows) { + if (next_rows.find(item.first) != next_rows.end()) continue; + const auto element = state_->elements.find(item.first); + if (element == state_->elements.end()) continue; + element->second->retired_ = true; + retired.push_back(element->second); + state_->elements.erase(element); + } + state_->rows = std::move(next_rows); + state_->row_order = std::move(next_order); + for (int64_t id : added_selection) { + const auto row = state_->rows.find(id); + if (row == state_->rows.end()) continue; + const EVENTID event = next_selected_count[row->second.parent] == 1 + ? UIA_SelectionItem_ElementSelectedEventId + : UIA_SelectionItem_ElementAddedToSelectionEventId; + if (Node *node = retainElementLocked(id)) { + selection_events.emplace_back(node, event); + } + } + for (int64_t id : removed_selection) { + if (Node *node = retainElementLocked(id)) { + selection_events.emplace_back(node, UIA_SelectionItem_ElementRemovedFromSelectionEventId); + } + } + allow_reclaim_changed = old_allow_reclaim != state_->allow_reclaim; + confirm_reclaim_changed = old_confirm_reclaim != state_->confirm_each_reclaim; + if (allow_reclaim_changed) allow_reclaim_node = retainElementLocked(12); + if (confirm_reclaim_changed) confirm_reclaim_node = retainElementLocked(13); + const int64_t old_focus = state_->focused; + if (!isKeyAvailableLocked(state_->focused)) state_->focused = 0; + focus_changed = old_focus != state_->focused; + if (status_changed) status_node = retainElementLocked(6); + if (focus_changed) focus_node = retainElementLocked(state_->focused); + } + for (Node *node : retired) node->Release(); + for (const auto &event : selection_events) { + UiaRaiseAutomationEvent( + static_cast(event.first), event.second); + event.first->Release(); + } + raiseToggleChanged(allow_reclaim_node, !allow_reclaim, allow_reclaim); + raiseToggleChanged(confirm_reclaim_node, !confirm_each_reclaim, confirm_each_reclaim); + if (structure_changed) { + UiaRaiseStructureChangedEvent( + static_cast(this), + StructureChangeType_ChildrenInvalidated, nullptr, 0); + } + raiseStatusChanged(status_node, old_status, new_status); + if (focus_node) { + UiaRaiseAutomationEvent( + static_cast(focus_node), + UIA_AutomationFocusChangedEventId); + focus_node->Release(); + } + } + void setStatus(const char *status) { + std::wstring old_status; + std::wstring new_status; + Node *status_node = nullptr; + bool status_changed = false; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + old_status = state_->status; + state_->status = wide(status); + new_status = state_->status; + status_changed = old_status != new_status; + if (status_changed) status_node = retainElementLocked(6); + } + raiseStatusChanged(status_node, old_status, new_status); + } + void shutdown() { + if (id_ != 0) return; + std::vector elements; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + state_->active = false; + state_->root = nullptr; + for (const auto &item : state_->elements) { + item.second->retired_ = true; + elements.push_back(item.second); + } + state_->elements.clear(); + } + for (Node *node : elements) node->Release(); + } + + private: + bool retired_ = false; + + bool supportsInvoke() const { + if (id_ == 0 || (id_ >= 7 && id_ <= 20)) return true; + std::lock_guard lock(state_->mutex); + const auto row = state_->rows.find(id_); + return row != state_->rows.end() && row->second.invokable; + } + bool isAvailableLocked() const { + return state_->active && !retired_ && isKeyAvailableLocked(id_); + } + bool isKeyAvailableLocked(int64_t id) const { + return !isRowKey(id) || state_->rows.find(id) != state_->rows.end(); + } + bool isAvailableRow() const { + std::lock_guard lock(state_->mutex); + return isAvailableLocked() && isRowKey(id_); + } + int64_t rowKeyForIdentityLocked( + const std::string &identity, + const std::unordered_map &pending) const { + int64_t key = static_cast( + static_cast(kRowPrefix) | (hashPath(identity) & kRowPayloadMask)); + while (state_->rows.find(key) != state_->rows.end() && + state_->rows.at(key).identity != identity) { + key = nextRowKey(key); + } + while (pending.find(key) != pending.end()) key = nextRowKey(key); + return key; + } + static int64_t nextRowKey(int64_t key) { + const uint64_t payload = (static_cast(key) + 1) & kRowPayloadMask; + return static_cast(static_cast(kRowPrefix) | payload); + } + Node *elementLocked(int64_t id) { + if (!state_->active || !isKeyAvailableLocked(id)) return nullptr; + if (id == 0) return state_->root; + const auto existing = state_->elements.find(id); + if (existing != state_->elements.end()) return existing->second; + Node *node = new Node(state_, id); + if (!node) return nullptr; + state_->elements.emplace(id, node); + return node; + } + Node *retainElementLocked(int64_t id) { + Node *node = elementLocked(id); + if (node) node->AddRef(); + return node; + } + int64_t parentLocked() const { + if (id_ == 0) return -1; + if (isRowKey(id_)) return state_->rows.at(id_).parent; + if (id_ >= 1 && id_ <= 6) return 0; + if (id_ >= 7 && id_ <= 13) return 5; + if (id_ == 14 || id_ == 15) return 1; + if (id_ >= 16 && id_ <= 20) return 4; + return -1; + } + int64_t firstChildLocked() const { + if (id_ == 0) return 1; + if (id_ >= 1 && id_ <= 4) { + for (int64_t key : state_->row_order) + if (state_->rows.at(key).parent == id_) return key; + } + if (id_ == 5) return 7; + if (id_ == 1) return 14; + if (id_ == 4) return 16; + return -1; + } + int64_t lastChildLocked() const { + if (id_ == 0) return 6; + if (id_ == 2 || id_ == 3) { + for (auto current = state_->row_order.rbegin(); current != state_->row_order.rend(); ++current) + if (state_->rows.at(*current).parent == id_) return *current; + } + if (id_ == 5) return 13; + if (id_ == 1) return 15; + if (id_ == 4) return 20; + return -1; + } + int64_t siblingLocked(int delta) const { + if (isRowKey(id_)) { + std::vector siblings; + const int parent = state_->rows.at(id_).parent; + for (int64_t key : state_->row_order) + if (state_->rows.at(key).parent == parent) siblings.push_back(key); + const auto current = std::find(siblings.begin(), siblings.end(), id_); + if (current == siblings.end()) return -1; + const auto index = current - siblings.begin() + delta; + if (index < 0) return -1; + if (index >= static_cast(siblings.size())) { + return parent == 1 ? 14 : parent == 4 ? 16 : -1; + } + return siblings[static_cast(index)]; + } + if (id_ >= 1 && id_ <= 6) { + const int64_t next = id_ + delta; + return next >= 1 && next <= 6 ? next : -1; + } + if (id_ >= 7 && id_ <= 13) { + const int64_t next = id_ + delta; + return next >= 7 && next <= 13 ? next : -1; + } + if (id_ >= 14 && id_ <= 15) { + if (id_ == 14 && delta < 0) { + for (auto current = state_->row_order.rbegin(); current != state_->row_order.rend(); ++current) + if (state_->rows.at(*current).parent == 1) return *current; + } + const int64_t next = id_ + delta; + return next >= 14 && next <= 15 ? next : -1; + } + if (id_ >= 16 && id_ <= 20) { + if (id_ == 16 && delta < 0) { + for (auto current = state_->row_order.rbegin(); current != state_->row_order.rend(); ++current) + if (state_->rows.at(*current).parent == 4) return *current; + } + const int64_t next = id_ + delta; + return next >= 16 && next <= 20 ? next : -1; + } + return -1; + } + std::wstring nameLocked() const { + if (isRowKey(id_)) return state_->rows.at(id_).name; + if (id_ == 6) return state_->status; + static const wchar_t *names[] = { + L"GraphCode UIA Root", L"Projects", L"Loops", L"Worktrees", L"Graph", L"Actions", + L"Status", L"Inspect worktrees", L"Reclaim selected worktrees", L"Reveal in Explorer", + L"Edit worktree policy", L"Save worktree policy", L"Allow reclaim", L"Confirm each reclaim", + L"Graph", L"Quick Chats", L"New Loop or Chat", L"Zoom out", + L"Actual size", L"Zoom in", L"Fit canvas", + }; + return names[id_ >= 0 && id_ <= 20 ? id_ : 0]; + } + std::wstring automationIdLocked() const { + if (isRowKey(id_)) { + const Row &row = state_->rows.at(id_); + const int parent = row.parent; + const wchar_t *prefix = + row.identity.rfind("sidebar-section:", 0) == 0 ? L"sidebar-section-" : + row.identity.rfind("project-new-loop:", 0) == 0 ? L"project-new-loop-" : + row.identity.rfind("project-disclosure:", 0) == 0 ? L"project-disclosure-" : + row.identity.rfind("quick-chats-header:", 0) == 0 ? L"quick-chats-header-" : + row.identity.rfind("quick-chat-new:", 0) == 0 ? L"quick-chat-new-" : + row.identity.rfind("quick-chats-disclosure:", 0) == 0 ? L"quick-chats-disclosure-" : + row.identity.rfind("quick-chat-row:", 0) == 0 ? L"quick-chat-row-" : + row.identity.rfind("loop-disclosure:", 0) == 0 ? L"loop-disclosure-" : + parent == 1 ? L"project-row-" : + parent == 2 ? L"loop-row-" : + parent == 3 ? L"worktree-row-" : L"canvas-card-"; + return prefix + std::to_wstring(id_); + } + static const wchar_t *ids[] = { + L"graphcode-root", L"projects", L"loops", L"worktrees", L"graph", L"actions", + L"status", L"inspect-worktrees", L"reclaim-worktrees", L"reveal-worktree", + L"edit-worktree-policy", L"save-worktree-policy", L"allow-reclaim", L"confirm-each-reclaim", + L"overview-destination", L"quick-chats-destination", L"canvas-primary-action", + L"zoom-out", L"actual-size", L"zoom-in", L"fit-canvas", + }; + return ids[id_ >= 0 && id_ <= 20 ? id_ : 0]; + } + CONTROLTYPEID controlTypeLocked() const { + if (id_ == 0) return UIA_WindowControlTypeId; + if (id_ >= 1 && id_ <= 3) return UIA_ListControlTypeId; + if (isRowKey(id_)) { + const Row &row = state_->rows.at(id_); + const bool action = + row.identity.rfind("sidebar-section:", 0) == 0 || + row.identity.rfind("project-new-loop:", 0) == 0 || + row.identity.rfind("project-disclosure:", 0) == 0 || + row.identity.rfind("quick-chats-header:", 0) == 0 || + row.identity.rfind("quick-chat-new:", 0) == 0 || + row.identity.rfind("quick-chats-disclosure:", 0) == 0 || + row.identity.rfind("loop-disclosure:", 0) == 0; + return row.parent == 4 || action ? UIA_ButtonControlTypeId : UIA_ListItemControlTypeId; + } + if ((id_ >= 7 && id_ <= 11) || (id_ >= 14 && id_ <= 20)) return UIA_ButtonControlTypeId; + if (id_ == 12 || id_ == 13) return UIA_CheckBoxControlTypeId; + if (id_ == 5) return UIA_MenuControlTypeId; + if (id_ == 6) return UIA_StatusBarControlTypeId; + return UIA_PaneControlTypeId; + } + HRESULT focusedElement(IRawElementProviderFragment **value) { + if (!value) return E_POINTER; + *value = nullptr; + Node *focused = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + focused = retainElementLocked(state_->focused); + } + if (focused) *value = static_cast(focused); + return S_OK; + } + void raiseStatusChanged( + Node *status_node, + const std::wstring &old_status, + const std::wstring &new_status) { + if (!status_node) return; + if (old_status == new_status) { + status_node->Release(); + return; + } + auto *provider = static_cast(status_node); + UiaRaiseAutomationEvent(provider, UIA_LiveRegionChangedEventId); + VARIANT old_value, new_value; + VariantInit(&old_value); + VariantInit(&new_value); + old_value.vt = VT_BSTR; + old_value.bstrVal = SysAllocString(old_status.c_str()); + new_value.vt = VT_BSTR; + new_value.bstrVal = SysAllocString(new_status.c_str()); + UiaRaiseAutomationPropertyChangedEvent(provider, UIA_NamePropertyId, old_value, new_value); + VariantClear(&old_value); + VariantClear(&new_value); + status_node->Release(); + } + void raiseToggleChanged(Node *toggle_node, bool old_value, bool new_value) { + if (!toggle_node) return; + VARIANT old_state, new_state; + VariantInit(&old_state); + VariantInit(&new_state); + old_state.vt = VT_I4; + old_state.lVal = old_value ? ToggleState_On : ToggleState_Off; + new_state.vt = VT_I4; + new_state.lVal = new_value ? ToggleState_On : ToggleState_Off; + UiaRaiseAutomationPropertyChangedEvent( + static_cast(toggle_node), + UIA_ToggleToggleStatePropertyId, old_state, new_state); + toggle_node->Release(); + } + HRESULT setSelected(SelectionOperation operation) { + HWND hwnd = nullptr; + bool changed = false; + bool dynamic_invoke = false; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || !isRowKey(id_)) + return UIA_E_ELEMENTNOTAVAILABLE; + const auto selected = state_->rows.find(id_); + if (selected->second.parent != 3) { + if (operation == kRemove || !selected->second.invokable) + return UIA_E_INVALIDOPERATION; + hwnd = state_->hwnd; + dynamic_invoke = true; + } else { + if (!selected->second.eligible) return UIA_E_INVALIDOPERATION; + if (operation == kSelect) { + for (auto &item : state_->rows) { + if (item.first == id_ || item.second.parent != 3) continue; + if (item.second.selected) changed = true; + item.second.selected = false; + } + } + const bool desired = operation != kRemove; + changed = changed || selected->second.selected != desired; + selected->second.selected = desired; + hwnd = state_->hwnd; + } + } + if (dynamic_invoke) { + const WPARAM command = kDynamicInvokeTag | + (static_cast(id_) & kRowPayloadMask); + PostMessageW(hwnd, WM_COMMAND, command, 0); + return S_OK; + } + const WPARAM command = kSelectionCommandTag | + (static_cast(operation) << kSelectionOperationShift) | + (static_cast(id_) & kRowPayloadMask); + PostMessageW(hwnd, WM_COMMAND, command, 0); + if (changed) { + EVENTID event = UIA_SelectionItem_ElementSelectedEventId; + if (operation == kAdd) event = UIA_SelectionItem_ElementAddedToSelectionEventId; + if (operation == kRemove) event = UIA_SelectionItem_ElementRemovedFromSelectionEventId; + UiaRaiseAutomationEvent( + static_cast(this), event); + } + return S_OK; + } + + std::shared_ptr state_; + int64_t id_; + volatile LONG refs_; +}; + +} // namespace + +extern "C" IRawElementProviderSimple *gc_uia_create(HWND hwnd) { + auto state = std::make_shared(); + state->hwnd = hwnd; + return new Node(std::move(state), 0); +} + +extern "C" void gc_uia_release(IRawElementProviderSimple *provider) { + if (provider) { + static_cast(provider)->shutdown(); + provider->Release(); + } +} + +extern "C" LRESULT gc_uia_get_object(HWND hwnd, WPARAM wparam, LPARAM lparam, + IRawElementProviderSimple *provider) { + if (!provider || lparam != UiaRootObjectId) return 0; + return UiaReturnRawElementProvider(hwnd, wparam, lparam, provider); +} + +extern "C" HRESULT gc_uia_update(IRawElementProviderSimple *provider, const char *status, + const char **identities, const char **names, + const int *parents, const int *selected, + const int *eligible, const int *invokable, + const int *bounds, int count, int allow_reclaim, + int confirm_each_reclaim) { + if (!provider || count < 0) return E_INVALIDARG; + auto *root = static_cast(provider); + root->update(status, identities, names, parents, selected, eligible, + invokable, bounds, count, + allow_reclaim != 0, confirm_each_reclaim != 0); + return S_OK; +} + +extern "C" HRESULT gc_uia_set_status(IRawElementProviderSimple *provider, const char *status) { + if (!provider) return E_INVALIDARG; + static_cast(provider)->setStatus(status); + return S_OK; +} diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig new file mode 100644 index 00000000..19d5e141 --- /dev/null +++ b/graphcode-windows/src/App.zig @@ -0,0 +1,4637 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const DaemonClient = @import("DaemonClient.zig").DaemonClient; +const GraphCanvas = @import("GraphCanvas.zig"); +const CanvasInput = @import("CanvasInput.zig"); +const CanvasLayoutStore = @import("CanvasLayoutStore.zig"); +const GraphContextMenu = @import("GraphContextMenu.zig"); +const Forms = @import("Forms.zig"); +const NativeForms = @import("NativeForms.zig"); +const JumpPalette = @import("JumpPalette.zig"); +const NativeDialogs = @import("WindowsNativeDialogs.zig"); +const Sidebar = @import("Sidebar.zig"); +const GraphModel = @import("GraphModel.zig"); +const InputRouter = @import("InputRouter.zig"); +const MainWindow = @import("MainWindow.zig"); +const TerminalWorkspace = @import("TerminalWorkspace.zig"); +const Tokens = @import("DesignTokens.zig"); +const Wire = @import("Wire.zig"); +const WorktreeStatus = @import("WorktreeStatus.zig"); +const TrayModule = @import("Tray.zig"); +const Tray = TrayModule.Tray; +const DaemonSupervisor = @import("DaemonSupervisor.zig").Supervisor; +const ProductSettings = @import("WindowsProductSettings.zig"); +const RepositoryDialogs = @import("WindowsRepositoryDialogs.zig"); +const Onboarding = @import("WindowsOnboarding.zig"); +const WindowsUpdates = @import("WindowsUpdates.zig"); +const WorktreeDialog = @import("WorktreeDialog.zig"); +const Accessibility = @import("Accessibility.zig"); +const Navigation = @import("Navigation.zig"); +const WorkspaceControls = @import("WorkspaceControls.zig"); +const c = @import("Win32.zig").c; + +const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); +const instance_prefix = "Local\\graphcode-windows-"; +const tray_test_hook_environment = "GRAPHCODE_TRAY_TEST_HOOK"; +const daemon_supervisor_test_hook_environment = "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"; +const daemon_supervisor_test_property = + std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.DaemonSupervisorState"); +extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; + +const InputBounds = struct { + rail_left: i32, + workspace_top: i32, + canvas: GraphCanvas.RenderBounds, +}; + +const WheelRegion = enum { sidebar, canvas, none }; + +fn inputBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControls.State) InputBounds { + return .{ + .rail_left = if (controls.rail_visible) Tokens.sidebar_width else 0, + .workspace_top = client_bottom - (if (controls.panel_visible) Tokens.workspace_height else 0), + .canvas = GraphCanvas.renderBounds(client_right, client_bottom, controls), + }; +} + +fn wheelRegion(x: i32, y: i32, bounds: InputBounds, controls: WorkspaceControls.State) WheelRegion { + if (controls.rail_visible and x < bounds.rail_left and + y >= Tokens.header_height and y < bounds.canvas.bottom) + { + return .sidebar; + } + if (x >= bounds.canvas.left and x < bounds.canvas.right and + y >= bounds.canvas.top and y < bounds.canvas.bottom) + { + return .canvas; + } + return .none; +} + +fn isResolvedLoopState(state: []const u8) bool { + return std.mem.eql(u8, state, "succeeded") or + std.mem.eql(u8, state, "failed") or + std.mem.eql(u8, state, "stalled") or + std.mem.eql(u8, state, "stopped"); +} + +fn workspaceGraph(model: *const GraphModel.Model) ?*const GraphModel.GraphSummary { + if (model.currentGraph()) |graph| if (graph.nodes.items.len != 0) return graph; + if (model.selected_project_path) |path| { + for (model.graphs.items) |*graph| { + if (std.mem.eql(u8, graph.project.path, path) and graph.nodes.items.len != 0) return graph; + } + } + for (model.graphs.items) |*graph| if (graph.nodes.items.len != 0) return graph; + return model.currentGraph(); +} + +const JumpMatch = struct { + project_index: usize, + node_index: usize, + score: u8, +}; + +fn findJumpMatch(model: *const GraphModel.Model, query: []const u8) ?JumpMatch { + var best: ?JumpMatch = null; + for (model.graphs.items, 0..) |graph, project_index| { + for (graph.nodes.items, 0..) |node, node_index| { + const score: u8 = if (std.ascii.eqlIgnoreCase(node.id, query)) + 0 + else if (std.ascii.eqlIgnoreCase(node.title, query)) + 1 + else if (asciiStartsWithIgnoreCase(node.title, query)) + 2 + else if (asciiContainsIgnoreCase(node.title, query) or asciiContainsIgnoreCase(node.id, query)) + 3 + else + continue; + if (best == null or score < best.?.score) + best = .{ .project_index = project_index, .node_index = node_index, .score = score }; + } + } + return best; +} + +fn asciiStartsWithIgnoreCase(value: []const u8, prefix: []const u8) bool { + return value.len >= prefix.len and std.ascii.eqlIgnoreCase(value[0..prefix.len], prefix); +} + +fn asciiContainsIgnoreCase(value: []const u8, needle: []const u8) bool { + if (needle.len == 0 or needle.len > value.len) return false; + var index: usize = 0; + while (index + needle.len <= value.len) : (index += 1) { + if (std.ascii.eqlIgnoreCase(value[index .. index + needle.len], needle)) return true; + } + return false; +} + +const UiaDynamicTarget = union(enum) { + local_section, + remote_section, + quick_chats_header, + quick_chats_disclosure, + new_quick_chat, + recent_project: []const u8, + open_project: []const u8, + project_new_loop: []const u8, + project_disclosure: []const u8, + loop: struct { + project_path: []const u8, + index: usize, + }, + loop_disclosure: struct { + project_path: []const u8, + index: usize, + }, + active_loop: usize, + composite_back, + quick_chat: []const u8, +}; + +pub const App = struct { + allocator: std.mem.Allocator, + window: MainWindow.Window = .{}, + client: DaemonClient, + daemon: DaemonSupervisor, + tray: Tray = .{}, + tray_test_hook_enabled: bool = false, + model: GraphModel.Model, + canvas: GraphCanvas.CanvasState = .{}, + selected_node_id: []u8 = &.{}, + selected_edge_project_path: []u8 = &.{}, + selected_edge_id: []u8 = &.{}, + edge_drag_source_id: []u8 = &.{}, + selection_initialized: bool = false, + worktree_inspection: ?WorktreeStatus.Inspection = null, + selected_worktree_path: []u8 = &.{}, + reclaim_confirmation_armed: bool = false, + worktree_dialog: ?WorktreeDialog.Dialog = null, + accessibility: ?Accessibility.Provider = null, + sidebar_scroll: i32 = 0, + sidebar_state: Sidebar.State, + sidebar_store: ?Sidebar.Store = null, + sidebar_hover_y: i32 = -1, + workspace: ?*TerminalWorkspace.Workspace = null, + navigation_cursor: Navigation.Cursor = .{}, + workspace_controls: WorkspaceControls.State = .{ .panel_visible = false }, + surface: GraphCanvas.Surface = .project, + canvas_layout_store: ?CanvasLayoutStore.Store = null, + quick_chats_requested: bool = false, + selected_quick_chat: ?usize = null, + instance_mutex: c.HANDLE = null, + sync_requested: bool = false, + restore_requested: bool = false, + open_project_pending: bool = false, + pending_rebind_path: []u8 = &.{}, + pending_previous_subscription: []u8 = &.{}, + open_generation: u64 = 0, + pending_open_generation: u64 = 0, + pending_open_request_id: ?[36]u8 = null, + pending_open_sent: bool = false, + pending_sent_path: []u8 = &.{}, + last_connection_state: Wire.ConnectionState = .disconnected, + last_project_opened: []const u8 = "", + accepted_subscription: []const u8 = "", + pending_project_path: []u8 = &.{}, + status_override: []u8 = &.{}, + ingress_error: []u8 = &.{}, + declared_entry_ids: std.array_list.Managed([]u8), + kept_worktree_paths: std.array_list.Managed([]u8), + running: bool = true, + exit_requested: bool = false, + smoke: bool = false, + stress: bool = false, + require_smoke_contract: bool = false, + smoke_failure: bool = false, + smoke_tick: usize = 0, + smoke_action_requested: bool = false, + smoke_input_requested: bool = false, + smoke_idle_ticks: usize = 0, + smoke_workspace_actions: []const u8 = "", + smoke_workspace_action_index: usize = 0, + smoke_workspace_actions_ran: bool = false, + smoke_workspace_action_failed: bool = false, + smoke_workspace_create_observed: bool = false, + smoke_workspace_split_observed: bool = false, + smoke_workspace_select_observed: bool = false, + smoke_workspace_focus_observed: bool = false, + smoke_workspace_close_observed: bool = false, + smoke_workspace_restart_observed: bool = false, + empty_open_folder_button: c.HWND = null, + empty_global_overview_button: c.HWND = null, + product_settings_store: ?ProductSettings.Store = null, + product_settings: ?ProductSettings.Settings = null, + onboarding_store: ?Onboarding.Store = null, + clone_operation: ?*RepositoryDialogs.CloneOperation = null, + activity_enabled: bool = false, + update_state: WindowsUpdates.CheckState = .{}, + update_lock: std.Thread.Mutex = .{}, + update_thread: ?std.Thread = null, + update_done: bool = false, + update_cancel: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + update_generation: u64 = 0, + update_pending: bool = false, + update_version: []u8 = &.{}, + update_release_url: []u8 = &.{}, + smoke_restart_index: ?usize = null, + smoke_restart_session: []const u8 = &.{}, + + pub fn init(allocator: std.mem.Allocator) !*App { + var client = try DaemonClient.init(allocator); + const app = allocator.create(App) catch |err| { + client.deinit(); + return err; + }; + const accessibility = Accessibility.defaultContract(allocator) catch |err| { + client.deinit(); + allocator.destroy(app); + return err; + }; + app.* = .{ + .allocator = allocator, + .client = client, + .daemon = .{ .allocator = allocator }, + .model = GraphModel.Model.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + .tray_test_hook_enabled = envFlag(tray_test_hook_environment), + .accessibility = accessibility, + .sidebar_state = Sidebar.State.init(allocator), + }; + errdefer app.deinit(); + try app.client.start(); + try app.acquireSingleInstance(); + return app; + } + + fn sendPendingOpen(self: *App) void { + if (self.pending_rebind_path.len == 0 or + self.client.connectionState() != .connected or + (self.client.protocolMode() == .v1 and self.pending_open_sent)) + return; + if (self.client.protocolMode() == .v1) { + self.client.setSubscription(self.pending_rebind_path); + } + if (self.pending_sent_path.len != 0) self.allocator.free(self.pending_sent_path); + self.pending_sent_path = self.allocator.dupe(u8, self.pending_rebind_path) catch { + self.setStatus("Unable to retain sent project"); + return; + }; + const token = self.client.sendOpenProject(self.pending_sent_path); + if (token == null) { + self.allocator.free(self.pending_sent_path); + self.pending_sent_path = &.{}; + self.open_project_pending = true; + return; + } + self.pending_open_request_id = token; + self.pending_open_sent = true; + self.open_project_pending = false; + } + + pub fn deinit(self: *App) void { + if (self.workspace) |workspace| { + workspace.deinit(); + self.allocator.destroy(workspace); + } + self.client.deinit(); + self.daemon.stop(); + self.tray.remove(); + self.model.deinit(); + if (self.accessibility) |*provider| provider.deinit(); + if (self.worktree_dialog) |*dialog| dialog.deinit(); + if (self.worktree_inspection) |*inspection| { + WorktreeStatus.deinitInspection(self.allocator, inspection); + } + if (self.selected_worktree_path.len != 0) self.allocator.free(self.selected_worktree_path); + if (self.selected_node_id.len != 0) self.allocator.free(self.selected_node_id); + if (self.selected_edge_project_path.len != 0) self.allocator.free(self.selected_edge_project_path); + if (self.selected_edge_id.len != 0) self.allocator.free(self.selected_edge_id); + if (self.edge_drag_source_id.len != 0) self.allocator.free(self.edge_drag_source_id); + if (self.instance_mutex != null) _ = c.CloseHandle(self.instance_mutex); + if (self.last_project_opened.len != 0) self.allocator.free(self.last_project_opened); + if (self.accepted_subscription.len != 0) self.allocator.free(self.accepted_subscription); + if (self.pending_project_path.len != 0) self.allocator.free(self.pending_project_path); + if (self.pending_rebind_path.len != 0) self.allocator.free(self.pending_rebind_path); + if (self.pending_sent_path.len != 0) self.allocator.free(self.pending_sent_path); + if (self.pending_previous_subscription.len != 0) self.allocator.free(self.pending_previous_subscription); + if (self.status_override.len != 0) self.allocator.free(self.status_override); + if (self.ingress_error.len != 0) self.allocator.free(self.ingress_error); + for (self.declared_entry_ids.items) |id| self.allocator.free(id); + self.declared_entry_ids.deinit(); + for (self.kept_worktree_paths.items) |path| self.allocator.free(path); + self.kept_worktree_paths.deinit(); + if (self.smoke_workspace_actions.len != 0) self.allocator.free(self.smoke_workspace_actions); + if (self.smoke_restart_session.len != 0) self.allocator.free(self.smoke_restart_session); + if (self.product_settings_store) |*store| store.deinit(); + if (self.canvas_layout_store) |*store| store.deinit(); + if (self.sidebar_store) |*store| store.deinit(); + self.sidebar_state.deinit(); + if (self.product_settings) |*settings| settings.deinit(); + if (self.onboarding_store) |*store| store.deinit(); + if (self.clone_operation) |operation| operation.deinit(); + self.update_cancel.store(true, .release); + if (self.update_thread) |thread| thread.join(); + if (self.update_version.len != 0) self.allocator.free(self.update_version); + if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); + self.allocator.destroy(self); + } + + pub fn run(self: *App) !void { + const com_result = c.CoInitializeEx(null, c.COINIT_APARTMENTTHREADED); + if (com_result < 0) return error.ComInitializationFailed; + defer c.CoUninitialize(); + try self.window.create(self, &onWindowMessage, title.ptr); + self.tray.test_hook_enabled = self.tray_test_hook_enabled; + self.tray.add(self.window.hwnd) catch self.setStatus("System tray unavailable; GraphCode remains open"); + const endpoint = self.client.currentEndpointName(self.allocator) catch &.{}; + const lock_name = self.client.currentDaemonLockName(self.allocator) catch &.{}; + defer if (endpoint.len != 0) self.allocator.free(endpoint); + defer if (lock_name.len != 0) self.allocator.free(lock_name); + if (endpoint.len != 0 and lock_name.len != 0) self.daemon.start(endpoint, lock_name); + if (envFlag(daemon_supervisor_test_hook_environment)) { + const state: usize = if (self.daemon.owned) 1 else if (self.daemon.status().len == 0) 2 else 3; + _ = c.SetPropW( + self.window.hwnd, + daemon_supervisor_test_property.ptr, + @ptrFromInt(state), + ); + } + if (self.daemon.status().len != 0) self.setStatus(self.daemon.status()); + if (self.accessibility) |*provider| { + if (!provider.attach(self.window.hwnd)) self.setStatus("Accessibility provider unavailable"); + } + self.product_settings_store = ProductSettings.Store.init(self.allocator) catch null; + if (self.product_settings_store) |*store| { + self.product_settings = store.load() catch |err| blk: { + self.setStatus(if (err == error.FileNotFound) "Product settings unavailable" else "Product settings could not be loaded"); + break :blk null; + }; + } + if (self.product_settings) |settings| { + self.activity_enabled = settings.activity; + self.workspace_controls.activity_enabled = settings.activity; + self.update_state = WindowsUpdates.CheckState.configure(settings.beta); + } + self.canvas_layout_store = CanvasLayoutStore.Store.init(self.allocator) catch null; + if (self.canvas_layout_store) |*store| { + store.load(&self.canvas) catch self.setStatus("Saved canvas positions could not be loaded"); + } + self.sidebar_store = Sidebar.Store.init(self.allocator) catch null; + if (self.sidebar_store) |*store| { + store.load(&self.sidebar_state) catch self.setStatus("Saved sidebar expansion could not be loaded"); + } + self.onboarding_store = Onboarding.Store.init(self.allocator) catch null; + if (self.onboarding_store) |store| { + const shell_test = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON") catch null; + defer if (shell_test) |value| self.allocator.free(value); + if (!envFlag("GRAPHCODE_UIA_GATE") and + !envFlag(daemon_supervisor_test_hook_environment) and + (shell_test == null or !std.mem.eql(u8, shell_test.?, "1"))) + { + const initial_backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; + if (Onboarding.showFirstRun(self.window.hwnd, self.allocator, store, initial_backend) catch null) |backend| { + self.applyOnboardingBackend(backend); + } + } + } + self.createEmptyStateControls(); + self.updateNativeChrome(); + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS")) |fixture| { + defer self.allocator.free(fixture); + self.installUiaFixture(); + if (envFlag("GRAPHCODE_UIA_SHOW_SWEEP")) self.presentWorktreeSweep(); + } else |_| {} + const uia_gate = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_GATE") catch null; + defer if (uia_gate) |value| self.allocator.free(value); + if (uia_gate == null or !std.mem.eql(u8, uia_gate.?, "1")) { + self.workspace = try TerminalWorkspace.Workspace.init(self.window.hwnd, self.allocator); + if (self.workspace) |workspace| workspace.setKeyCallback(self, &onWorkspaceKey); + if (self.workspace) |workspace| try workspace.startInputWorker(); + } + self.layoutWorkspace(); + if (!envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) self.requestUpdateCheck(); + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON")) |value| { + defer self.allocator.free(value); + self.require_smoke_contract = std.mem.eql(u8, value, "1"); + } else |_| {} + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_WORKSPACE_ACTIONS")) |value| { + self.smoke_workspace_actions = value; + } else |_| {} + self.client.setCallback(&onDaemonFrame, self); + self.client.connect(); + try self.window.messageLoop(); + if (self.exit_requested) std.process.exit(0); + if (self.smoke_failure) { + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_EXPECT_TRANSPORT_ERROR")) |value| { + defer self.allocator.free(value); + if (std.mem.eql(u8, value, "1")) { + std.debug.print("Smoke daemon status: {s}\n", .{self.client.statusText()}); + } + } else |_| {} + return error.SmokeContractFailed; + } + } + + pub fn configureArgs(self: *App, args: []const []const u8) void { + for (args) |arg| { + if (std.mem.eql(u8, arg, "--smoke")) self.smoke = true; + if (std.mem.eql(u8, arg, "--stress")) self.stress = true; + } + } + + fn onFrame(self: *App, frame: []const u8) void { + var incoming_project_path: ?[]u8 = null; + defer if (incoming_project_path) |path| self.allocator.free(path); + if (self.pending_rebind_path.len != 0 and Wire.eventKind(frame) == .graph_changed) { + const path = Wire.copyGraphChangedProjectPath(self.allocator, frame) catch null; + if (path) |value| { + incoming_project_path = value; + if (self.client.protocolMode() == .v1 and self.pending_open_sent) { + if (!std.mem.eql(u8, value, self.pending_sent_path) and + !std.mem.eql(u8, value, self.accepted_subscription)) return; + } else if (!Wire.isCurrentGraphPath( + self.pending_rebind_path, + self.accepted_subscription, + value, + )) return; + } + } + const event = self.model.updateFromFrame(frame) catch { + self.setStatus("Malformed GraphcodeKit event"); + return; + }; + switch (event) { + .recent_projects => { + self.clampSidebarScroll(); + if (self.model.graph == null and self.model.recent_projects.items.len != 0) { + self.queueProject(self.model.recent_projects.items[0].path); + } + }, + .graph_changed => { + if (incoming_project_path) |path| { + if (self.pending_rebind_path.len != 0 and + (std.mem.eql(u8, path, self.pending_rebind_path) or + (self.client.protocolMode() == .v1 and + std.mem.eql(u8, path, self.pending_sent_path)))) + { + if (self.selectProject(path) and + (self.selected_edge_project_path.len == 0 or + !std.mem.eql(u8, self.selected_edge_project_path, path))) + { + self.clearEdgeSelection(); + } + } + } + if (self.model.graph) |graph| { + if (self.canvas.selected_edge) |edge| { + if (edge >= graph.edges.items.len) self.canvas.selected_edge = null; + } + const accepted = incoming_project_path != null and + self.pending_rebind_path.len != 0 and + std.mem.eql(u8, incoming_project_path.?, graph.project.path); + if (accepted) { + self.clearIngressError(); + self.client.setSubscription(graph.project.path); + if (self.last_project_opened.len != 0) self.allocator.free(self.last_project_opened); + self.last_project_opened = self.allocator.dupe(u8, graph.project.path) catch { + self.setStatus("Unable to retain accepted project"); + return; + }; + if (self.accepted_subscription.len != 0) self.allocator.free(self.accepted_subscription); + self.accepted_subscription = self.allocator.dupe(u8, graph.project.path) catch { + self.setStatus("Unable to retain accepted subscription"); + return; + }; + const queued_v1 = self.client.protocolMode() == .v1 and + !std.mem.eql(u8, self.pending_rebind_path, graph.project.path); + if (self.pending_sent_path.len != 0) { + self.allocator.free(self.pending_sent_path); + self.pending_sent_path = &.{}; + } + self.pending_open_sent = false; + self.pending_open_request_id = null; + if (!queued_v1) { + self.allocator.free(self.pending_rebind_path); + self.pending_rebind_path = &.{}; + self.pending_open_generation = 0; + if (self.pending_previous_subscription.len != 0) { + self.allocator.free(self.pending_previous_subscription); + self.pending_previous_subscription = &.{}; + } + } + self.open_project_pending = queued_v1; + if (queued_v1) { + if (self.pending_previous_subscription.len != 0) self.allocator.free(self.pending_previous_subscription); + self.pending_previous_subscription = self.allocator.dupe(u8, graph.project.path) catch &.{}; + } + self.rebindWorkspace(graph.project.path); + if (queued_v1) self.sendPendingOpen(); + } else if (self.pending_rebind_path.len == 0) { + self.rebindWorkspace(graph.project.path); + } + if (self.canvas.selected_edge) |edge| { + if (edge >= graph.edges.items.len) self.canvas.selected_edge = null; + } + self.remapSelection(); + self.rebindWorkspace(graph.project.path); + self.clampSidebarScroll(); + if (self.worktree_inspection) |inspection| { + if (self.model.graph) |current_graph| { + if (!std.mem.eql(u8, inspection.project_path, current_graph.project.path)) { + WorktreeStatus.deinitInspection(self.allocator, &self.worktree_inspection.?); + self.worktree_inspection = null; + if (self.worktree_dialog) |*dialog| { + dialog.deinit(); + self.worktree_dialog = null; + } + if (self.selected_worktree_path.len != 0) { + self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + } + self.reclaim_confirmation_armed = false; + } + } + } + if (self.pending_rebind_path.len == 0) { + if (self.model.graph) |current_graph| self.queueProject(current_graph.project.path); + } + self.clampSidebarScroll(); + self.refreshWorkspace(); + } + }, + .quick_chats, .quick_chat_changed, .quick_chat_deleted, .quick_chat_activity => { + if (event == .quick_chat_changed) { + if (Wire.jsonString(frame, "id")) |id| self.openQuickChat(id); + } + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .error_occurred => { + const ingress_failure = self.pending_rebind_path.len != 0; + if (self.pending_rebind_path.len != 0) { + if (self.client.protocolMode() == .v2) { + const request_id = self.pending_open_request_id orelse return; + const response_id = Wire.responseRequestID(frame) orelse return; + if (!std.mem.eql(u8, response_id, &request_id)) return; + } else if (!self.pending_open_sent) { + return; + } + const queued_v1 = self.client.protocolMode() == .v1 and + !std.mem.eql(u8, self.pending_rebind_path, self.pending_sent_path); + self.client.setSubscription(self.pending_previous_subscription); + if (self.last_project_opened.len != 0) self.allocator.free(self.last_project_opened); + self.last_project_opened = self.allocator.dupe(u8, self.pending_previous_subscription) catch &.{}; + if (self.accepted_subscription.len != 0) self.allocator.free(self.accepted_subscription); + self.accepted_subscription = self.allocator.dupe(u8, self.pending_previous_subscription) catch &.{}; + if (self.pending_sent_path.len != 0) { + self.allocator.free(self.pending_sent_path); + self.pending_sent_path = &.{}; + } + self.pending_open_sent = false; + self.pending_open_request_id = null; + if (!queued_v1) { + self.allocator.free(self.pending_rebind_path); + self.pending_rebind_path = &.{}; + self.pending_open_generation = 0; + if (self.pending_previous_subscription.len != 0) { + self.allocator.free(self.pending_previous_subscription); + self.pending_previous_subscription = &.{}; + } + } + self.open_project_pending = queued_v1; + if (queued_v1) self.sendPendingOpen(); + } + if (Wire.copyErrorMessage(self.allocator, frame) catch null) |message| { + if (ingress_failure) self.setIngressError(message); + self.replaceStatus(message); + } else { + if (ingress_failure) self.setIngressError("Daemon could not open the selected project"); + self.setStatus("Daemon error"); + } + }, + else => {}, + } + } + + fn refreshWorkspace(self: *App) void { + const workspace = if (self.workspace) |value| value else return; + const graph = if (self.model.graph) |value| value else return; + if (graph.nodes.items.len > 0 and !workspace.hasSurface(0)) { + workspace.openNode(0, graph.nodes.items[0].id) catch { + self.setStatus("Unable to attach terminal A"); + }; + } + + if (graph.nodes.items.len > 1 and !workspace.hasSurface(1)) { + workspace.openNode(1, graph.nodes.items[1].id) catch { + self.setStatus("Unable to attach terminal B"); + }; + } + } + + fn rebindWorkspace(self: *App, path: []const u8) void { + if (self.workspace) |workspace| { + _ = workspace.rebindProject(path) catch { + self.setStatus("Unable to rebind workspace project"); + return; + }; + } + } + + pub fn openProject(self: *App, path: []const u8) void { + if (path.len == 0) return; + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + const previous = if (self.pending_rebind_path.len != 0) + self.allocator.dupe(u8, self.pending_previous_subscription) catch { + self.setStatus("Unable to retain previous project subscription"); + return; + } + else if (self.accepted_subscription.len != 0) + self.allocator.dupe(u8, self.accepted_subscription) catch { + self.setStatus("Unable to retain previous project subscription"); + return; + } + else + self.client.subscriptionPath(self.allocator) catch { + self.setStatus("Unable to retain previous project subscription"); + return; + }; + const pending = self.allocator.dupe(u8, path) catch { + self.allocator.free(previous); + self.setStatus("Unable to retain pending project"); + return; + }; + const opened = self.allocator.dupe(u8, path) catch { + self.allocator.free(previous); + self.allocator.free(pending); + self.setStatus("Unable to retain project subscription"); + return; + }; + if (self.pending_previous_subscription.len != 0) self.allocator.free(self.pending_previous_subscription); + const v1_busy = self.client.protocolMode() == .v1 and self.pending_open_sent; + self.pending_previous_subscription = previous; + self.open_generation +%= 1; + self.pending_open_generation = self.open_generation; + self.pending_open_request_id = null; + if (!v1_busy) self.client.setSubscription(path); + if (self.last_project_opened.len != 0) self.allocator.free(self.last_project_opened); + self.last_project_opened = opened; + if (self.pending_rebind_path.len != 0) self.allocator.free(self.pending_rebind_path); + self.pending_rebind_path = pending; + self.open_project_pending = true; + if (!v1_busy) self.sendPendingOpen(); + } + + pub fn openFolder(self: *App) void { + self.clearIngressError(); + var path: [32768]u16 = undefined; + const picked = graphcode_pick_folder(self.window.hwnd, &path, path.len); + if (picked < 0) { + self.setIngressError("Unable to open the folder picker"); + self.setStatus("Unable to open the folder picker"); + return; + } + if (picked == 0) return; + var length: usize = 0; + while (length < path.len and path[length] != 0) : (length += 1) {} + const utf8 = std.unicode.utf16LeToUtf8Alloc(self.allocator, path[0..length]) catch { + self.setIngressError("Unable to read the selected folder"); + self.setStatus("Unable to read the selected folder"); + return; + }; + defer self.allocator.free(utf8); + self.openProject(utf8); + } + + pub fn openGlobalOverview(self: *App) void { + self.surface = .overview; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn queueProject(self: *App, path: []const u8) void { + if (path.len == 0 or + std.mem.eql(u8, self.last_project_opened, path) or + std.mem.eql(u8, self.pending_project_path, path)) + { + return; + } + const copy = self.allocator.dupe(u8, path) catch { + self.setStatus("Unable to retain pending project subscription"); + return; + }; + if (self.pending_project_path.len != 0) self.allocator.free(self.pending_project_path); + self.pending_project_path = copy; + } + + fn flushPendingProject(self: *App) void { + if (self.pending_project_path.len == 0 or self.client.connectionState() != .connected) return; + const path = self.pending_project_path; + self.pending_project_path = &.{}; + self.openProject(path); + self.allocator.free(path); + } + + fn currentProject(self: *const App) ?[]const u8 { + if (self.model.currentGraph()) |graph| return graph.project.path; + if (self.model.recent_projects.items.len != 0) return self.model.recent_projects.items[0].path; + if (self.worktree_inspection) |inspection| return inspection.project_path; + return null; + } + + fn selectProject(self: *App, path: []const u8) bool { + const selected = self.model.selectProject(path); + if (selected) self.client.setSubgraphAddress(null); + return selected; + } + + fn replaceSelectionID(self: *App, destination: *[]u8, value: []const u8) bool { + const copy = self.allocator.dupe(u8, value) catch return false; + if (destination.*.len != 0) self.allocator.free(destination.*); + destination.* = copy; + return true; + } + + fn clearNodeSelection(self: *App) void { + self.model.selected_index = null; + if (self.selected_node_id.len != 0) { + self.allocator.free(self.selected_node_id); + self.selected_node_id = &.{}; + } + } + + fn clearEdgeSelection(self: *App) void { + self.canvas.selected_edge = null; + self.canvas.selected_edge_id = ""; + if (self.selected_edge_project_path.len != 0) { + self.allocator.free(self.selected_edge_project_path); + self.selected_edge_project_path = &.{}; + } + if (self.selected_edge_id.len != 0) { + self.allocator.free(self.selected_edge_id); + self.selected_edge_id = &.{}; + } + } + + fn clearSelection(self: *App) void { + self.selection_initialized = true; + self.clearNodeSelection(); + self.clearEdgeSelection(); + } + + fn cancelCanvasInteraction(self: *App) void { + self.canvas.cancelInteraction(); + if (self.edge_drag_source_id.len != 0) { + self.allocator.free(self.edge_drag_source_id); + self.edge_drag_source_id = &.{}; + } + _ = c.ReleaseCapture(); + } + + fn copyEdgeDragSourceForDrop(self: *App) ?[]u8 { + const source_id = self.canvas.endEdgeDrag() orelse return null; + return self.allocator.dupe(u8, source_id) catch { + self.cancelCanvasInteraction(); + return null; + }; + } + + fn selectNodeIndex(self: *App, index: usize) bool { + const graph = self.model.graph orelse return false; + if (index >= graph.nodes.items.len) return false; + if (!self.replaceSelectionID(&self.selected_node_id, graph.nodes.items[index].id)) return false; + if (!self.model.setSelectedIndex(index)) return false; + self.selection_initialized = true; + self.clearEdgeSelection(); + return true; + } + + fn selectEdgeIndex(self: *App, index: usize) bool { + const graph = self.model.graph orelse return false; + if (index >= graph.edges.items.len) return false; + if (graph.edges.items[index].id.len != 0) { + if (!self.replaceSelectionID(&self.selected_edge_id, graph.edges.items[index].id)) return false; + if (!self.replaceSelectionID(&self.selected_edge_project_path, graph.project.path)) return false; + } else { + if (self.selected_edge_id.len != 0) self.allocator.free(self.selected_edge_id); + self.selected_edge_id = &.{}; + } + self.canvas.selected_edge = index; + self.canvas.selected_edge_id = self.selected_edge_id; + self.selection_initialized = true; + self.clearNodeSelection(); + return true; + } + + fn remapSelection(self: *App) void { + if (self.edge_drag_source_id.len != 0) { + if (self.model.findNodeIndex(self.edge_drag_source_id) == null) { + self.cancelCanvasInteraction(); + } + } + if (!self.selection_initialized) { + if (self.model.graph) |graph| { + if (graph.nodes.items.len != 0) { + _ = self.selectNodeIndex(0); + return; + } + } + } + if (self.selected_node_id.len != 0) { + self.model.selected_index = self.model.findNodeIndex(self.selected_node_id); + if (self.model.selected_index == null) { + self.clearNodeSelection(); + } + } else { + self.model.selected_index = null; + } + if (self.selected_edge_id.len != 0 and + self.selected_edge_project_path.len != 0 and + self.model.currentGraph() != null and + std.mem.eql(u8, self.model.currentGraph().?.project.path, self.selected_edge_project_path)) + { + self.canvas.selected_edge = GraphModel.findEdgeIndexByID( + self.model.graph.?.edges.items, + self.selected_edge_id, + ); + if (self.canvas.selected_edge == null) { + self.clearEdgeSelection(); + } else { + self.canvas.selected_edge_id = self.selected_edge_id; + } + } else { + self.canvas.selected_edge = null; + self.canvas.selected_edge_id = ""; + } + } + + fn createQuickChat(self: *App) void { + self.client.sendCreateQuickChat("Chat", "claudeCode"); + self.setStatus("Creating quick chat..."); + } + + fn renameSelectedQuickChat(self: *App) void { + const index = self.selected_quick_chat orelse return; + if (index >= self.model.quick_chats.items.len) return; + const chat = self.model.quick_chats.items[index]; + var result = NativeDialogs.text( + self.window.hwnd, + self.allocator, + "Rename Quick Chat", + &.{"Title"}, + &.{chat.title}, + ) catch { + self.setStatus("Unable to open quick chat rename form"); + return; + } orelse return; + defer result.deinit(self.allocator); + const title_value = std.mem.trim(u8, result.values[0], " \t\r\n"); + if (title_value.len == 0) { + self.setStatus("Invalid quick chat title"); + return; + } + self.client.sendRenameQuickChat(chat.id, title_value); + } + + fn deleteSelectedQuickChat(self: *App) void { + const index = self.selected_quick_chat orelse return; + if (index >= self.model.quick_chats.items.len) return; + const chat = self.model.quick_chats.items[index]; + const message = std.fmt.allocPrint( + self.allocator, + "Delete \"{s}\"?\n\nIts terminal session and scrollback will be removed. This cannot be undone.", + .{chat.title}, + ) catch return; + defer self.allocator.free(message); + if (!GraphContextMenu.confirm(self.window.hwnd, "Delete Quick Chat", message)) return; + self.client.sendDeleteQuickChat(chat.id); + } + + fn openQuickChat(self: *App, id: []const u8) void { + for (self.model.quick_chats.items, 0..) |chat, index| { + if (!std.mem.eql(u8, chat.id, id)) continue; + self.selected_quick_chat = index; + self.surface = .workspace; + self.workspace_controls.panel_visible = true; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + if (self.workspace) |workspace| { + if (std.process.getEnvVarOwned(self.allocator, "USERPROFILE")) |home| { + defer self.allocator.free(home); + _ = workspace.rebindProject(home) catch {}; + } else |_| {} + workspace.openNode(0, chat.id) catch { + self.setStatus("Unable to open quick chat workspace"); + }; + } + return; + } + } + + fn selectNextNode(self: *App) void { + const graph = self.model.graph orelse return; + if (graph.nodes.items.len == 0) { + self.clearNodeSelection(); + return; + } + const next = if (self.model.selected_index) |index| + (index + 1) % graph.nodes.items.len + else + 0; + _ = self.selectNodeIndex(next); + } + + fn selectNextAttention(self: *App) void { + self.model.selectNextAttention(); + if (!self.model.isCompositeOpen()) self.client.setSubgraphAddress(null); + if (self.model.selected_index) |index| _ = self.selectNodeIndex(index); + } + + fn selectedEdgeIndex(self: *const App) ?usize { + if (self.selected_edge_id.len == 0 or self.selected_edge_project_path.len == 0) return null; + const graph = self.model.graph orelse return null; + if (!std.mem.eql(u8, graph.project.path, self.selected_edge_project_path)) return null; + return GraphModel.findEdgeIndexByID(graph.edges.items, self.selected_edge_id); + } + + fn createNode(self: *App) void { + const current_path = self.currentProject() orelse if (self.surface == .overview) + "graphcode://global" + else + return; + const path = self.allocator.dupe(u8, current_path) catch return; + defer self.allocator.free(path); + const settings = self.product_settings orelse return; + var draft = NativeForms.node(self.window.hwnd, self.allocator, .{ + .title = "", + .backend = settings.default_backend, + .model_tier = settings.default_model, + .claude_permissions = settings.claude_permissions, + .copilot_permissions = settings.copilot_permissions, + .briefing_enabled = settings.briefing, + .activity_enabled = settings.activity, + }) catch { + self.setStatus("Unable to open node form"); + return; + } orelse return; + defer draft.deinit(self.allocator); + Forms.validateNode(draft) catch { + self.setStatus("Invalid node form"); + return; + }; + self.client.sendCreateNodeDraft(path, draft); + } + + fn editSelectedNode(self: *App) void { + const graph = self.model.graph orelse return; + const index = self.model.selectedIndex() orelse return; + if (index >= graph.nodes.items.len) return; + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const node_id = self.allocator.dupe(u8, graph.nodes.items[index].id) catch return; + defer self.allocator.free(node_id); + var result = NativeDialogs.textWithDescription( + self.window.hwnd, + self.allocator, + "Rename Loop", + "Choose the title shown for this loop throughout the graph.", + &.{"Title"}, + &.{graph.nodes.items[index].title}, + ) catch { + self.setStatus("Unable to open loop rename form"); + return; + } orelse return; + defer result.deinit(self.allocator); + const title_value = std.mem.trim(u8, result.values[0], " \t\r\n"); + if (title_value.len == 0) { + self.setStatus("Loop title cannot be empty"); + return; + } + const updated_graph = self.model.graph orelse return; + if (!std.mem.eql(u8, updated_graph.project.path, project_path)) return; + const updated_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, node_id) orelse { + self.setStatus("Loop changed while renaming"); + return; + }; + self.client.sendRenameNode(project_path, updated_graph.nodes.items[updated_index].id, title_value); + } + + fn createEdge(self: *App) void { + const graph = self.model.graph orelse return; + if (graph.nodes.items.len < 2) return; + const path = self.currentProject() orelse return; + const endpoints = self.allocator.alloc(NativeForms.EdgeEndpoint, graph.nodes.items.len) catch { + self.setStatus("Unable to prepare edge endpoints"); + return; + }; + defer self.allocator.free(endpoints); + for (graph.nodes.items, endpoints) |node, *endpoint| endpoint.* = .{ + .id = node.id, + .title = node.title, + }; + var draft = NativeForms.edgeWithEndpoints(self.window.hwnd, self.allocator, .{ + .from = graph.nodes.items[0].id, + .to = graph.nodes.items[1].id, + .kind = "handoff", + }, endpoints, false) catch { + self.setStatus("Unable to open edge form"); + return; + } orelse return; + defer draft.deinit(self.allocator); + Forms.validateEdge(draft) catch { + self.setStatus("Invalid edge form"); + return; + }; + self.client.sendCreateEdgeDraft(path, draft); + } + + fn createEdgeBetween(self: *App, source: usize, target: usize) void { + const graph = self.model.graph orelse return; + if (source >= graph.nodes.items.len or target >= graph.nodes.items.len or source == target) return; + self.createEdgeBetweenIDs(graph.nodes.items[source].id, graph.nodes.items[target].id); + } + + fn createEdgeBetweenIDs(self: *App, source_id: []const u8, target_id: []const u8) void { + const graph = self.model.graph orelse return; + if (std.mem.eql(u8, source_id, target_id)) return; + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const from_id = self.allocator.dupe(u8, source_id) catch return; + defer self.allocator.free(from_id); + const to_id = self.allocator.dupe(u8, target_id) catch return; + defer self.allocator.free(to_id); + const draft = NativeForms.edge(self.window.hwnd, self.allocator, .{ + .from = from_id, + .to = to_id, + .kind = "handoff", + }) catch { + self.setStatus("Unable to open edge form"); + return; + } orelse return; + defer self.allocator.free(draft.from); + defer self.allocator.free(draft.to); + defer self.allocator.free(draft.kind); + Forms.validateEdge(draft) catch { + self.setStatus("Invalid edge form"); + return; + }; + const updated_graph = self.model.graph orelse return; + const from_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, draft.from) orelse { + self.setStatus("Source loop changed while creating edge"); + return; + }; + const to_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, draft.to) orelse { + self.setStatus("Target loop changed while creating edge"); + return; + }; + self.client.sendCreateEdge(project_path, updated_graph.nodes.items[from_index].id, updated_graph.nodes.items[to_index].id, draft.kind); + } + + fn openSettings(self: *App) void { + const initial = self.client.effectiveSettings(self.allocator) catch { + self.setStatus("Unable to load current settings"); + return; + }; + defer self.allocator.free(initial.daemon_pipe); + defer self.allocator.free(initial.support_directory); + const draft = NativeForms.settings(self.window.hwnd, self.allocator, initial) catch { + self.setStatus("Unable to open settings form"); + return; + } orelse return; + defer self.allocator.free(draft.daemon_pipe); + defer self.allocator.free(draft.support_directory); + self.client.applySettings(draft.daemon_pipe, draft.support_directory) catch { + self.setStatus("Invalid daemon settings"); + return; + }; + } + + fn openProductSettings(self: *App) void { + const store = if (self.product_settings_store) |*value| value else { + self.setStatus("Product settings storage unavailable"); + return; + }; + var current = store.load() catch { + self.setStatus("Unable to load product settings"); + return; + }; + defer current.deinit(); + const draft = ProductSettings.open(@intFromPtr(self.window.hwnd.?), self.allocator, current) catch { + self.setStatus("Unable to open product settings"); + return; + } orelse return; + store.save(draft) catch { + self.setStatus("Unable to save product settings"); + return; + }; + if (self.product_settings) |*settings| settings.deinit(); + self.product_settings = draft; + self.activity_enabled = draft.activity; + self.workspace_controls.activity_enabled = draft.activity; + self.update_lock.lock(); + self.update_state = WindowsUpdates.CheckState.configure(draft.beta); + self.update_lock.unlock(); + self.setStatus("Checking for updates…"); + self.requestUpdateCheck(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn applyOnboardingBackend(self: *App, backend: Onboarding.Backend) void { + const store = if (self.product_settings_store) |*value| value else { + self.setStatus("Onboarding choice could not be saved"); + return; + }; + if (self.product_settings) |*settings| { + const replacement = self.allocator.dupe(u8, backend.value()) catch { + self.setStatus("Onboarding choice could not be saved"); + return; + }; + self.allocator.free(settings.default_backend); + settings.default_backend = replacement; + store.save(settings.*) catch self.setStatus("Onboarding choice could not be saved"); + return; + } + var settings = ProductSettings.Settings.init(self.allocator) catch { + self.setStatus("Onboarding choice could not be saved"); + return; + }; + const replacement = self.allocator.dupe(u8, backend.value()) catch { + settings.deinit(); + self.setStatus("Onboarding choice could not be saved"); + return; + }; + self.allocator.free(settings.default_backend); + settings.default_backend = replacement; + store.save(settings) catch { + settings.deinit(); + self.setStatus("Onboarding choice could not be saved"); + return; + }; + self.product_settings = settings; + } + + fn cloneRepository(self: *App) void { + self.clearIngressError(); + const draft = RepositoryDialogs.openClone(self.window.hwnd, self.allocator, .{}) catch { + self.setIngressError("Unable to open clone repository dialog"); + self.setStatus("Unable to open clone repository dialog"); + return; + } orelse return; + defer { + self.allocator.free(draft.url); + self.allocator.free(draft.destination); + self.allocator.free(draft.branch); + self.allocator.free(draft.depth); + } + RepositoryDialogs.validateClone(draft) catch |err| { + self.setIngressError(@errorName(err)); + self.setStatus(@errorName(err)); + return; + }; + if (self.clone_operation != null) { + self.setIngressError("A clone is already running"); + self.setStatus("A clone is already running"); + return; + } + self.clone_operation = RepositoryDialogs.CloneOperation.start(self.allocator, draft) catch { + self.setIngressError("Clone could not start"); + self.setStatus("Clone could not start"); + return; + }; + self.setStatus("Cloning repository… (Ctrl+Shift+X cancels)"); + } + + fn cancelClone(self: *App) void { + if (self.clone_operation) |operation| { + operation.cancel(); + self.setStatus("Cancelling clone…"); + } + } + + pub fn checkForUpdates(self: *App) void { + self.setStatus("Checking for updates..."); + self.requestUpdateCheck(); + self.updateNativeChrome(); + } + + fn requestUpdateCheck(self: *App) void { + self.update_lock.lock(); + self.update_generation += 1; + self.update_pending = true; + if (self.update_thread != null) { + self.update_cancel.store(true, .release); + self.update_lock.unlock(); + return; + } + self.update_pending = false; + self.update_cancel.store(false, .release); + self.update_lock.unlock(); + self.launchUpdateCheck(); + } + + fn launchUpdateCheck(self: *App) void { + self.update_lock.lock(); + self.update_done = false; + self.update_cancel.store(false, .release); + self.update_lock.unlock(); + self.update_thread = std.Thread.spawn(.{}, updateWorker, .{self}) catch { + self.update_lock.lock(); + self.update_done = true; + self.update_lock.unlock(); + self.setStatus("Update check could not start"); + return; + }; + } + + fn updateWorker(self: *App) void { + self.update_lock.lock(); + const generation = self.update_generation; + const beta = self.update_state.channel == .beta; + self.update_lock.unlock(); + const version = WindowsUpdates.currentVersionFromMetadata(self.allocator, build_options.version) catch { + self.update_lock.lock(); + if (generation == self.update_generation) self.update_state = .{ .channel = if (beta) .beta else .stable, .state = .failed }; + self.update_done = true; + self.update_lock.unlock(); + return; + }; + defer self.allocator.free(version); + var client = WindowsUpdates.CheckClient{ .allocator = self.allocator }; + const result = client.checkWithCancel(beta, version, &self.update_cancel) catch { + self.update_lock.lock(); + if (generation == self.update_generation and !self.update_cancel.load(.acquire)) + self.update_state = .{ .channel = if (beta) .beta else .stable, .state = .failed }; + self.update_done = true; + self.update_lock.unlock(); + return; + }; + defer { + var owned = result; + owned.deinit(self.allocator); + } + const version_copy = if (result.version) |value| self.allocator.dupe(u8, value) catch null else null; + const url_copy = if (result.release_url) |value| self.allocator.dupe(u8, value) catch null else null; + self.update_lock.lock(); + if (generation == self.update_generation and !self.update_cancel.load(.acquire)) { + self.update_state = .{ .channel = result.channel, .state = result.state }; + if (self.update_version.len != 0) self.allocator.free(self.update_version); + if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); + self.update_version = version_copy orelse &.{}; + self.update_release_url = url_copy orelse &.{}; + } else { + if (version_copy) |value| self.allocator.free(value); + if (url_copy) |value| self.allocator.free(value); + } + self.update_done = true; + self.update_lock.unlock(); + } + + fn finishUpdateCheck(self: *App) void { + self.update_lock.lock(); + const done = self.update_done; + self.update_lock.unlock(); + if (!done) return; + if (self.update_thread) |thread| { + thread.join(); + self.update_thread = null; + self.update_lock.lock(); + const pending = self.update_pending; + self.update_pending = false; + const label = self.update_state.label(); + const available = self.update_state.state == .available; + const version = self.update_version; + const release_url = self.update_release_url; + self.update_lock.unlock(); + if (pending) { + self.launchUpdateCheck(); + } else { + self.setStatus(label); + if (available) self.showAvailableUpdate(version, release_url); + } + } + } + + fn showAvailableUpdate(self: *App, version: []const u8, release_url: []const u8) void { + const message = std.fmt.allocPrint( + self.allocator, + "GraphCode {s} is available.\n\nOpen the verified GitHub release page to review release notes and download the Windows package?", + .{if (version.len == 0) "update" else version}, + ) catch return; + defer self.allocator.free(message); + const message_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch return; + defer self.allocator.free(message_wide); + if (c.MessageBoxW( + self.window.hwnd, + message_wide.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Update Available").ptr, + c.MB_ICONINFORMATION | c.MB_YESNO | c.MB_DEFBUTTON1, + ) != c.IDYES) return; + const url = if (release_url.len != 0) release_url else "https://github.com/GraphCode/GraphCode/releases"; + const url_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, url) catch { + self.setStatus("Unable to encode the release URL"); + return; + }; + defer self.allocator.free(url_wide); + const result = c.ShellExecuteW( + self.window.hwnd, + std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, + url_wide.ptr, + null, + null, + c.SW_SHOWNORMAL, + ); + self.setStatus(if (@intFromPtr(result) <= 32) "Unable to open the release page" else "Opened the GraphCode release page"); + } + + fn showCurrentUpdateOffer(self: *App) void { + self.update_lock.lock(); + const available = self.update_state.state == .available; + const version = if (available) self.allocator.dupe(u8, self.update_version) catch null else null; + const release_url = if (available) self.allocator.dupe(u8, self.update_release_url) catch null else null; + self.update_lock.unlock(); + defer if (version) |value| self.allocator.free(value); + defer if (release_url) |value| self.allocator.free(value); + if (!available) return; + self.showAvailableUpdate(version orelse "", release_url orelse ""); + } + + fn addRemoteRepository(self: *App) void { + self.clearIngressError(); + const draft = RepositoryDialogs.openRemote(self.window.hwnd, self.allocator, .{}) catch { + self.setIngressError("Unable to open SSH repository dialog"); + self.setStatus("Unable to open SSH repository dialog"); + return; + } orelse return; + defer { + self.allocator.free(draft.host); + self.allocator.free(draft.user); + self.allocator.free(draft.port); + self.allocator.free(draft.path); + } + RepositoryDialogs.validateRemote(draft) catch |err| { + self.setIngressError(@errorName(err)); + self.setStatus(@errorName(err)); + return; + }; + RepositoryDialogs.validateRemoteConnection(self.allocator, draft) catch |err| { + self.setIngressError(@errorName(err)); + self.setStatus(@errorName(err)); + return; + }; + RepositoryDialogs.saveRemoteConfig(self.allocator, draft) catch { + self.setIngressError("SSH validated but remote configuration could not be saved"); + self.setStatus("SSH validated but remote configuration could not be saved"); + return; + }; + const remote_path = RepositoryDialogs.remoteProjectURI(self.allocator, draft) catch { + self.setIngressError("Unable to encode remote repository"); + self.setStatus("Unable to encode remote repository"); + return; + }; + defer self.allocator.free(remote_path); + _ = self.client.sendOpenProject(remote_path); + self.client.reconnect(); + self.setStatus("SSH repository connected; reconnect requested"); + } + + fn jumpToNode(self: *App) void { + if (self.model.graphs.items.len == 0) { + self.setStatus("No graph is open"); + return; + } + var entries = std.array_list.Managed(JumpPalette.Entry).init(self.allocator); + defer entries.deinit(); + for (self.model.graphs.items) |graph| { + for (graph.nodes.items) |node| { + entries.append(.{ + .project_path = graph.project.path, + .project_name = graph.project.name, + .node_id = node.id, + .title = node.title, + .loop_type = node.loop_type, + .state = node.state, + }) catch { + self.setStatus("Unable to collect jump results"); + return; + }; + } + } + const selection = JumpPalette.show(self.window.hwnd, self.allocator, entries.items) catch { + self.setStatus("Unable to open jump palette"); + return; + } orelse return; + defer selection.deinit(self.allocator); + const project_index = for (self.model.graphs.items, 0..) |graph, index| { + if (std.mem.eql(u8, graph.project.path, selection.project_path)) break index; + } else { + self.setStatus("Matching loop is no longer available"); + return; + }; + const node_index = for (self.model.graphs.items[project_index].nodes.items, 0..) |node, index| { + if (std.mem.eql(u8, node.id, selection.node_id)) break index; + } else { + self.setStatus("Matching loop is no longer available"); + return; + }; + if (!self.selectProject(selection.project_path) or !self.selectNodeIndex(node_index)) return; + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.sidebar_scroll = Sidebar.clampScroll( + Sidebar.loopRowTopForModel(&self.model, node_index) - 24, + Sidebar.maxScroll(&self.model, if (self.worktree_inspection) |*value| value else null, 700, &self.sidebar_state), + ); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn openSelectedNode(self: *App) void { + const graph = if (self.model.graph) |value| value else return; + const index = self.model.selectedIndex() orelse return; + if (index >= graph.nodes.items.len) return; + if (!self.model.isCompositeOpen() and + (std.mem.eql(u8, graph.nodes.items[index].loop_type, "composite") or + std.mem.eql(u8, graph.nodes.items[index].loop_type, "proactive"))) + { + self.showCompositeGroup(graph.nodes.items[index]); + return; + } + if (self.model.isCompositeOpen()) { + self.setStatus("Composite templates have no terminal until the group is piloted"); + return; + } + const workspace = if (self.workspace) |value| value else return; + self.surface = .workspace; + self.workspace_controls.panel_visible = true; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + workspace.openNode(0, graph.nodes.items[index].id) catch { + self.setStatus("Unable to open selected node"); + }; + } + + fn stopSelectedNode(self: *App) void { + const path = self.currentProject() orelse return; + const node = self.model.selected() orelse return; + self.client.sendNodeAction(path, node.id, "stopNode", null); + } + + fn sendSelectedNode(self: *App) void { + const path = self.currentProject() orelse return; + const node = self.model.selected() orelse return; + self.client.sendNodeAction(path, node.id, "messageNode", "GraphCode Windows shell message"); + } + + fn deleteSelectedNode(self: *App) void { + const graph = self.model.graph orelse return; + const index = self.model.selected_index orelse return; + if (index >= graph.nodes.items.len) return; + const path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(path); + const node_id = self.allocator.dupe(u8, graph.nodes.items[index].id) catch return; + defer self.allocator.free(node_id); + const message = std.fmt.allocPrint( + self.allocator, + "Delete \"{s}\"?\n\nThe loop and its graph connections will be removed. This cannot be undone.", + .{graph.nodes.items[index].title}, + ) catch return; + defer self.allocator.free(message); + if (!GraphContextMenu.confirm(self.window.hwnd, "Delete Loop", message)) return; + const updated_graph = self.model.graph orelse return; + const updated_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, node_id) orelse return; + self.client.sendDeleteNode(path, updated_graph.nodes.items[updated_index].id); + } + + fn editSelectedEdge(self: *App, index: usize) void { + const graph = self.model.graph orelse return; + if (index >= graph.edges.items.len) return; + const edge = graph.edges.items[index]; + if (!GraphContextMenu.canEditEdge(edge.id)) { + self.setStatus("Cannot edit an edge without a stable identifier"); + return; + } + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const edge_id = self.allocator.dupe(u8, edge.id) catch return; + defer self.allocator.free(edge_id); + const initial_from = self.allocator.dupe(u8, edge.from) catch return; + defer self.allocator.free(initial_from); + const initial_to = self.allocator.dupe(u8, edge.to) catch return; + defer self.allocator.free(initial_to); + const initial_kind = self.allocator.dupe(u8, edge.kind) catch return; + defer self.allocator.free(initial_kind); + const draft = NativeForms.edge(self.window.hwnd, self.allocator, .{ + .from = initial_from, + .to = initial_to, + .kind = initial_kind, + }) catch { + self.setStatus("Unable to open edge form"); + return; + } orelse return; + defer self.allocator.free(draft.from); + defer self.allocator.free(draft.to); + defer self.allocator.free(draft.kind); + Forms.validateEdge(draft) catch { + self.setStatus("Invalid edge form"); + return; + }; + const updated_graph = self.model.graph orelse return; + const updated_index = GraphModel.findEdgeIndexByID(updated_graph.edges.items, edge_id) orelse { + self.setStatus("Edge changed while editing"); + return; + }; + const from_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, draft.from) orelse { + self.setStatus("Source loop changed while editing edge"); + return; + }; + const to_index = GraphModel.findNodeIndexByID(updated_graph.nodes.items, draft.to) orelse { + self.setStatus("Target loop changed while editing edge"); + return; + }; + self.client.sendDeleteEdge(project_path, updated_graph.edges.items[updated_index].id); + self.client.sendCreateEdge( + project_path, + updated_graph.nodes.items[from_index].id, + updated_graph.nodes.items[to_index].id, + draft.kind, + ); + } + + fn deleteEdge(self: *App, index: usize) void { + const graph = self.model.graph orelse return; + if (index >= graph.edges.items.len) return; + const edge = graph.edges.items[index]; + if (!GraphContextMenu.canEditEdge(edge.id)) { + self.setStatus("This graph edge has no stable delete identifier"); + return; + } + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const edge_id = self.allocator.dupe(u8, edge.id) catch return; + defer self.allocator.free(edge_id); + const source = if (GraphModel.findNodeIndexByID(graph.nodes.items, edge.from)) |node_index| + graph.nodes.items[node_index].title + else + edge.from; + const target = if (GraphModel.findNodeIndexByID(graph.nodes.items, edge.to)) |node_index| + graph.nodes.items[node_index].title + else + edge.to; + const message = std.fmt.allocPrint( + self.allocator, + "Delete the connection \"{s}\" -> \"{s}\"?\n\nThis removes the {s} graph connection. The loops themselves remain.", + .{ source, target, edge.kind }, + ) catch return; + defer self.allocator.free(message); + if (!GraphContextMenu.confirm(self.window.hwnd, "Delete Edge", message)) return; + const updated_graph = self.model.graph orelse return; + const updated_index = GraphModel.findEdgeIndexByID(updated_graph.edges.items, edge_id) orelse return; + self.client.sendDeleteEdge(project_path, updated_graph.edges.items[updated_index].id); + } + + fn nodeIsDeclaredEntry(self: *const App, node_id: []const u8) bool { + for (self.declared_entry_ids.items) |id| if (std.mem.eql(u8, id, node_id)) return true; + return false; + } + + fn nodeIsUnwired(self: *const App, node_id: []const u8) bool { + const graph = self.model.graph orelse return false; + for (graph.edges.items) |edge| { + if (std.mem.eql(u8, edge.from, node_id) or std.mem.eql(u8, edge.to, node_id)) return false; + } + return !self.nodeIsDeclaredEntry(node_id); + } + + fn markSelectedNodeAsEntry(self: *App, index: usize) void { + const graph = self.model.graph orelse return; + if (index >= graph.nodes.items.len) return; + const id = graph.nodes.items[index].id; + if (!self.nodeIsDeclaredEntry(id)) { + const copy = self.allocator.dupe(u8, id) catch { + self.setStatus("Unable to remember the entry loop"); + return; + }; + self.declared_entry_ids.append(copy) catch { + self.allocator.free(copy); + self.setStatus("Unable to remember the entry loop"); + return; + }; + } + self.setStatus("Marked as an entry for this session"); + } + + fn beginWireSelectedNode(self: *App, index: usize) void { + const graph = self.model.graph orelse return; + if (index >= graph.nodes.items.len) return; + if (self.edge_drag_source_id.len != 0) self.allocator.free(self.edge_drag_source_id); + self.edge_drag_source_id = self.allocator.dupe(u8, graph.nodes.items[index].id) catch { + self.setStatus("Unable to start edge wiring"); + return; + }; + const bounds = GraphCanvas.nodeBounds(index, &self.canvas); + self.canvas.beginEdgeDrag( + self.edge_drag_source_id, + bounds.right, + @divTrunc(bounds.top + bounds.bottom, 2), + ); + _ = c.SetCapture(self.window.hwnd); + self.setStatus("Choose a target loop to wire this entry"); + } + + fn showNodeContextMenu(self: *App, index: usize, x: i32, y: i32) void { + const graph = self.model.graph orelse return; + if (index >= graph.nodes.items.len) return; + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const node_id = self.allocator.dupe(u8, graph.nodes.items[index].id) catch return; + defer self.allocator.free(node_id); + const composite = std.mem.eql(u8, graph.nodes.items[index].loop_type, "proactive") or + std.mem.eql(u8, graph.nodes.items[index].loop_type, "composite"); + const unwired = self.nodeIsUnwired(graph.nodes.items[index].id); + GraphContextMenu.show( + self.window.hwnd, + .{ .node = .{ + .project_path = project_path, + .id = node_id, + .composite = composite, + .can_arm = std.mem.eql(u8, graph.nodes.items[index].pilot_state, "piloted"), + .unwired = unwired, + } }, + x, + y, + self, + &onContextAction, + ); + } + + fn showEdgeContextMenu(self: *App, index: usize, x: i32, y: i32) void { + const graph = self.model.graph orelse return; + if (index >= graph.edges.items.len) return; + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const edge_id = self.allocator.dupe(u8, graph.edges.items[index].id) catch return; + defer self.allocator.free(edge_id); + GraphContextMenu.show( + self.window.hwnd, + .{ .edge = .{ .project_path = project_path, .id = edge_id } }, + x, + y, + self, + &onContextAction, + ); + } + + fn showQuickChatContextMenu(self: *App, index: usize, x: i32, y: i32) void { + if (index >= self.model.quick_chats.items.len) return; + const id = self.allocator.dupe(u8, self.model.quick_chats.items[index].id) catch return; + defer self.allocator.free(id); + GraphContextMenu.show( + self.window.hwnd, + .{ .quick_chat = .{ .id = id } }, + x, + y, + self, + &onContextAction, + ); + } + + fn handleContextAction(self: *App, action: GraphContextMenu.Action, target: GraphContextMenu.Target) void { + switch (target) { + .project => |stable| { + const selected = self.selectProject(stable.path); + if (selected) { + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + } + switch (action) { + .open_project => if (!selected) self.openProject(stable.path), + .new_project_loop => if (selected) + self.createNode() + else { + self.openProject(stable.path); + self.setStatus("Opening project; create a loop when loading completes"); + }, + .inspect_project_worktrees => if (selected) self.inspectWorktrees() else self.setStatus("Open the project before inspecting worktrees"), + .project_settings => if (selected) self.editWorktreePolicy() else self.setStatus("Open the project before changing project settings"), + .reveal_project => self.revealProjectPath(stable.path), + .remote_project_info => self.showRemoteProjectInfo(stable.path), + .close_project => { + self.client.sendCloseProject(stable.path); + self.setStatus("Closing project..."); + }, + .remove_project => { + if (!GraphContextMenu.confirm( + self.window.hwnd, + "Remove Project", + "Remove this project from GraphCode?\n\nThe folder and its files remain on disk. You can add it again later.", + )) return; + self.client.sendForgetProject(stable.path); + self.setStatus("Removing project from GraphCode..."); + }, + .delete_project_loops => { + self.deleteProjectLoops(stable.path); + }, + else => {}, + } + }, + .node => |stable| { + const already_active = if (self.model.graph) |active| + std.mem.eql(u8, active.project.path, stable.project_path) + else + false; + if (!already_active and !self.selectProject(stable.project_path)) return; + const graph = self.model.graph orelse return; + const index = GraphModel.findNodeIndexByID(graph.nodes.items, stable.id) orelse return; + if (!self.selectNodeIndex(index)) return; + switch (action) { + .rename_node => self.editSelectedNode(), + .stop_node => self.stopSelectedNode(), + .delete_node => self.deleteSelectedNode(), + .open_terminal => self.openSelectedNode(), + .message_node => self.sendSelectedNode(), + .memo_node => self.sendSelectedNode(), + .open_composite => self.showCompositeGroup(graph.nodes.items[index]), + .pilot_composite => { + self.client.sendPilotComposite(graph.project.path, graph.nodes.items[index].id); + self.setStatus("Piloting composite once..."); + }, + .arm_composite => { + if (std.mem.eql(u8, graph.nodes.items[index].pilot_state, "piloted")) { + self.client.sendArmComposite(graph.project.path, graph.nodes.items[index].id); + self.setStatus("Arming composite schedule..."); + } else { + self.setStatus("Pilot this composite successfully before arming it"); + } + }, + .wire_node => self.beginWireSelectedNode(index), + .mark_entry => self.markSelectedNodeAsEntry(index), + else => {}, + } + }, + .edge => |stable| { + const graph = self.model.graph orelse return; + if (!std.mem.eql(u8, graph.project.path, stable.project_path)) return; + if (stable.id.len == 0) return; + const index = GraphModel.findEdgeIndexByID(graph.edges.items, stable.id) orelse return; + if (!self.selectEdgeIndex(index)) return; + switch (action) { + .edit_edge => self.editSelectedEdge(index), + .delete_edge => self.deleteEdge(index), + else => {}, + } + }, + .quick_chat => |stable| { + var index: usize = 0; + while (index < self.model.quick_chats.items.len and + !std.mem.eql(u8, self.model.quick_chats.items[index].id, stable.id)) : (index += 1) + {} + if (index >= self.model.quick_chats.items.len) return; + self.selected_quick_chat = index; + switch (action) { + .open_quick_chat => { + self.client.sendOpenQuickChat(stable.id); + self.setStatus("Opening quick chat..."); + }, + .rename_quick_chat => self.renameSelectedQuickChat(), + .delete_quick_chat => self.deleteSelectedQuickChat(), + else => {}, + } + }, + .background => if (action == .create_edge) self.createEdge(), + .quick_chats => if (action == .new_quick_chat) self.createQuickChat(), + } + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn showCompositeGroup(self: *App, node: GraphModel.Node) void { + const node_id = self.allocator.dupe(u8, node.id) catch return; + defer self.allocator.free(node_id); + if (!self.model.openComposite(node_id)) { + self.setStatus("Unable to open composite group"); + return; + } + self.client.setSubgraphAddress(node_id); + self.clearEdgeSelection(); + self.canvas.actualSize(); + self.setStatus("Composite group opened · use the breadcrumb to return"); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn closeCompositeGroup(self: *App) void { + if (!self.model.isCompositeOpen()) return; + self.model.closeComposite(); + self.client.setSubgraphAddress(null); + self.clearEdgeSelection(); + self.canvas.actualSize(); + self.setStatus("Returned to project graph"); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn revealProjectPath(self: *App, path: []const u8) void { + const parameters = WorktreeStatus.explorerParameters(self.allocator, path) catch { + self.setStatus("Unable to prepare Explorer"); + return; + }; + defer self.allocator.free(parameters); + const wide_raw = std.unicode.utf8ToUtf16LeAlloc(self.allocator, parameters) catch { + self.setStatus("Unable to encode Explorer path"); + return; + }; + defer self.allocator.free(wide_raw); + const wide = self.allocator.alloc(u16, wide_raw.len + 1) catch return; + defer self.allocator.free(wide); + @memcpy(wide[0..wide_raw.len], wide_raw); + wide[wide_raw.len] = 0; + const result = c.ShellExecuteW( + self.window.hwnd, + std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("explorer.exe").ptr, + wide.ptr, + null, + c.SW_SHOWNORMAL, + ); + self.setStatus(if (@intFromPtr(result) <= 32) "Unable to open Explorer" else "Opened project in Explorer"); + } + + fn showRemoteProjectInfo(self: *App, path: []const u8) void { + const message = std.fmt.allocPrint( + self.allocator, + "Remote project\n\n{s}\n\nThe SSH connection is managed by GraphCode and can be changed by removing and adding the remote project again.", + .{path}, + ) catch return; + defer self.allocator.free(message); + const message_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch return; + defer self.allocator.free(message_wide); + _ = c.MessageBoxW( + self.window.hwnd, + message_wide.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Remote Connection").ptr, + c.MB_OK | c.MB_ICONINFORMATION, + ); + } + + fn deleteProjectLoops(self: *App, path: []const u8) void { + if (!GraphContextMenu.confirm( + self.window.hwnd, + "Delete All Loops", + "Delete every loop and graph connection for this project?\n\nThe project files remain on disk. This graph action cannot be undone.", + )) return; + self.client.sendDeleteProjectGraph(path); + self.setStatus("Deleting project loops..."); + } + + fn showAbout(self: *App) void { + const message = std.fmt.allocPrint( + self.allocator, + "GraphCode for Windows\nVersion {s}\n\nVisualize and orchestrate parallel coding-agent work.", + .{build_options.version}, + ) catch return; + defer self.allocator.free(message); + const message_wide = std.unicode.utf8ToUtf16LeAllocZ(self.allocator, message) catch return; + defer self.allocator.free(message_wide); + _ = c.MessageBoxW( + self.window.hwnd, + message_wide.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("About GraphCode").ptr, + c.MB_OK | c.MB_ICONINFORMATION, + ); + } + + fn inspectWorktrees(self: *App) void { + if (envFlag("GRAPHCODE_UIA_GATE") and envFlag("GRAPHCODE_UIA_SHOW_DIALOGS") and self.worktree_inspection != null) { + self.presentWorktreeSweep(); + return; + } + self.inspectWorktreesImpl(true); + } + + fn inspectWorktreesImpl(self: *App, show_sweep: bool) void { + const current_graph = self.model.graph orelse { + self.setStatus("Worktrees require a local filesystem project"); + return; + }; + if (!current_graph.project.isLocalFilesystem()) { + self.setStatus("Worktrees require a local filesystem project"); + return; + } + const path = current_graph.project.path; + if (path.len == 0) { + self.setStatus("No project selected for worktree inspection"); + return; + } + var bindings = std.array_list.Managed(WorktreeStatus.Binding).init(self.allocator); + defer bindings.deinit(); + if (self.model.graph) |graph| { + for (graph.nodes.items) |node| { + if (node.worktree_path.len != 0) bindings.append(.{ .path = node.worktree_path }) catch {}; + } + + } + const inspection = WorktreeStatus.inspect(self.allocator, path, bindings.items) catch |err| { + self.setStatus(switch (err) { + error.EmptyProjectPath => "Worktree inspection needs a project path", + error.GitFailed => "Worktree inspection failed: git returned an error", + else => "Worktree inspection failed", + }); + return; + }; + if (self.worktree_inspection) |*old| { + WorktreeStatus.deinitInspection(self.allocator, old); + } + if (self.worktree_dialog) |*dialog| { + dialog.deinit(); + self.worktree_dialog = null; + } + if (self.selected_worktree_path.len != 0) { + self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + } + self.worktree_inspection = inspection; + self.worktree_dialog = WorktreeDialog.Dialog.init( + self.allocator, + path, + inspection.entries.items, + WorktreeStatus.loadPolicy(self.allocator, path), + ) catch null; + self.syncAccessibility(); + self.clampSidebarScroll(); + const summary = WorktreeStatus.summarize(inspection.entries.items); + const message = std.fmt.allocPrint( + self.allocator, + "Worktrees: {d} total · {d} reclaimable · {d} blocked", + .{ summary.total, summary.reclaimable, summary.blocked }, + ) catch { + self.setStatus("Worktree inspection complete"); + return; + }; + self.replaceStatus(message); + if (show_sweep and !envFlag("GRAPHCODE_UIA_GATE")) self.presentWorktreeSweep(); + } + + fn presentWorktreeSweep(self: *App) void { + const graph = self.model.graph orelse return; + const inspection = self.worktree_inspection orelse return; + const project_path = self.allocator.dupe(u8, graph.project.path) catch return; + defer self.allocator.free(project_path); + const project_name = self.allocator.dupe(u8, graph.project.name) catch return; + defer self.allocator.free(project_name); + const result = NativeForms.worktreeSweep( + self.window.hwnd, + self.allocator, + project_name, + inspection.entries.items, + ) catch { + self.setStatus("Unable to open Worktree Sweep"); + return; + } orelse { + self.setStatus("Worktree Sweep cancelled"); + return; + }; + const current_graph = self.model.graph orelse { + self.setStatus("Project closed while Worktree Sweep was open"); + return; + }; + if (!std.mem.eql(u8, current_graph.project.path, project_path)) { + self.setStatus("Project changed while Worktree Sweep was open"); + return; + } + const current_inspection = self.worktree_inspection orelse { + self.setStatus("Worktree inspection expired"); + return; + }; + if (!std.mem.eql(u8, current_inspection.project_path, project_path)) { + self.setStatus("Worktree inspection no longer matches this project"); + return; + } + var selected = std.array_list.Managed([]const u8).init(self.allocator); + defer selected.deinit(); + for (current_inspection.entries.items[0..@min(current_inspection.entries.items.len, result.count)], 0..) |entry, index| { + if (result.selected[index] and WorktreeStatus.decision(entry) == .reclaimable) + selected.append(entry.path) catch { + self.setStatus("Unable to collect Worktree Sweep selection"); + return; + }; + } + if (selected.items.len == 0) { + self.setStatus("No safe worktrees selected"); + return; + } + var bindings = std.array_list.Managed(WorktreeStatus.Binding).init(self.allocator); + defer bindings.deinit(); + for (current_graph.nodes.items) |node| if (node.worktree_path.len != 0) { + bindings.append(.{ .path = node.worktree_path }) catch {}; + }; + var explicit_policy = WorktreeStatus.Policy{}; + explicit_policy.applyResolveAction(.remove); + const removed = WorktreeStatus.reclaimSelectedWithPolicy( + self.allocator, + project_path, + selected.items, + bindings.items, + explicit_policy, + true, + ) catch |err| { + self.setStatus(switch (err) { + error.UnsafeSelection => "Worktree Sweep blocked an unsafe selection", + error.GitFailed => "Worktree Sweep failed: git refused removal", + else => "Worktree Sweep failed", + }); + return; + }; + const message = std.fmt.allocPrint(self.allocator, "Worktree Sweep removed {d} worktrees", .{removed}) catch { + self.setStatus("Worktree Sweep complete"); + return; + }; + self.replaceStatus(message); + self.inspectWorktreesImpl(false); + } + + fn installUiaFixture(self: *App) void { + if (envFlag("GRAPHCODE_UIA_RESET_SIDEBAR")) self.sidebar_state.clearExpandedNodes(); + const graph_frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"uia-graph","project":{"path":"C:\\GraphCode\\fixture","name":"UIA project","remote":false},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"UIA loop A","loopType":"goalBased","state":"succeeded","activity":"checking tests","presence":{"presence":"idle","confidence":"reported"},"goal":{"summary":"All tests pass","predicate":"swift test","metric":{"command":"coverage","direction":"maximize"}},"modelTier":"capable","worktreeBinding":{"path":"C:\\fixture-safe","branch":"feature/parity"}},{"id":"22222222-2222-4222-8222-222222222222","title":"UIA loop B","loopType":"proactive","state":"running","activity":"needs response","presence":{"presence":"awaitingInput","confidence":"reported"},"subGraph":{"nodes":[{"id":"55555555-5555-4555-8555-555555555555","title":"UIA nested A","loopType":"turnBased","state":"idle"},{"id":"66666666-6666-4666-8666-666666666666","title":"UIA nested B","loopType":"goalBased","state":"running"}]}}],"edges":[{"id":"88888888-8888-4888-8888-888888888888","from":"11111111-1111-4111-8111-111111111111","to":"22222222-2222-4222-8222-222222222222","kind":"handoff"}]}}} + ; + const chats_frame = + \\{"version":2,"kind":"event","sequence":2,"event":{"quickChatsListed":[{"id":"33333333-3333-4333-8333-333333333333","title":"UIA chat A","backend":"claudeCode","createdAt":0,"activity":null},{"id":"44444444-4444-4444-8444-444444444444","title":"UIA chat B","backend":"copilot","createdAt":1,"activity":null}]}} + ; + const projects_frame = + \\{"version":2,"kind":"event","sequence":3,"event":{"recentProjectsListed":[{"path":"C:\\GraphCode\\fixture","name":"Fixture local"},{"path":"ssh://builder/GraphCode","name":"Fixture remote"}]}} + ; + _ = self.model.updateFromFrame(graph_frame) catch {}; + _ = self.model.updateFromFrame(chats_frame) catch {}; + _ = self.model.updateFromFrame(projects_frame) catch {}; + if (self.model.quick_chats.items.len == 0) { + self.model.quick_chats.append(.{ + .id = self.allocator.dupe(u8, "33333333-3333-4333-8333-333333333333") catch return, + .title = self.allocator.dupe(u8, "UIA chat A") catch return, + .backend = self.allocator.dupe(u8, "claudeCode") catch return, + }) catch return; + self.model.quick_chats.append(.{ + .id = self.allocator.dupe(u8, "44444444-4444-4444-8444-444444444444") catch return, + .title = self.allocator.dupe(u8, "UIA chat B") catch return, + .backend = self.allocator.dupe(u8, "copilot") catch return, + }) catch return; + } + const project = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_GATE_CWD") catch + self.allocator.dupe(u8, "C:\\GraphCode\\fixture") catch return; + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(self.allocator), + .default_branch = self.allocator.dupe(u8, "main") catch { + self.allocator.free(project); + return; + }, + .project_path = project, + }; + inspection.entries.append(.{ + .path = self.allocator.dupe(u8, "C:\\fixture-safe") catch return, + .branch = self.allocator.dupe(u8, "safe") catch return, + .pushed = true, + .landed = true, + }) catch return; + inspection.entries.append(.{ + .path = self.allocator.dupe(u8, "C:\\fixture-unsafe") catch return, + .branch = self.allocator.dupe(u8, "unsafe") catch return, + .dirty = true, + .pushed = true, + .landed = true, + }) catch return; + self.worktree_inspection = inspection; + self.worktree_dialog = WorktreeDialog.Dialog.init( + self.allocator, project, inspection.entries.items, .{ .allow_reclaim = true }, + ) catch null; + if (envFlag("GRAPHCODE_UIA_UPDATE_AVAILABLE")) { + self.update_lock.lock(); + self.update_state.state = .available; + if (self.update_version.len != 0) self.allocator.free(self.update_version); + if (self.update_release_url.len != 0) self.allocator.free(self.update_release_url); + self.update_version = self.allocator.dupe(u8, "9.9.9-test") catch &.{}; + self.update_release_url = self.allocator.dupe(u8, "https://github.com/GraphCode/GraphCode/releases/tag/v9.9.9-test") catch &.{}; + self.update_lock.unlock(); + } + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_INGRESS_ERROR")) |message| { + defer self.allocator.free(message); + self.setIngressError(message); + } else |_| {} + self.setStatus("UIA fixture inspection ready"); + } + + fn reclaimWorktrees(self: *App) void { + const current_graph = self.model.graph orelse { + self.setStatus("Worktrees require a local filesystem project"); + return; + }; + if (!current_graph.project.isLocalFilesystem()) { + self.setStatus("Worktrees require a local filesystem project"); + return; + } + const path = current_graph.project.path; + if (path.len == 0) { + self.setStatus("No project selected for worktree reclaim"); + return; + } + if (self.selected_worktree_path.len == 0 and + (self.worktree_dialog == null or self.worktree_dialog.?.selectedCount() == 0)) + { + self.setStatus("Select a worktree row before reclaiming"); + return; + } + const policy = WorktreeStatus.loadPolicy(self.allocator, path); + if (!policy.allow_reclaim) { + self.setStatus("Reclaim disabled by project worktree policy"); + return; + } + if (policy.confirm_each_reclaim and !self.reclaim_confirmation_armed) { + self.reclaim_confirmation_armed = true; + self.setStatus("Reclaim is destructive; press Ctrl+Shift+W again to confirm"); + return; + } + var selected_list = if (self.worktree_dialog) |*dialog| + dialog.selectedPaths(self.allocator) catch { + self.setStatus("Unable to collect selected worktrees"); + return; + } + else blk: { + var single = std.array_list.Managed([]const u8).init(self.allocator); + single.append(self.selected_worktree_path) catch { + single.deinit(); + self.setStatus("Unable to collect selected worktrees"); + return; + }; + break :blk single; + }; + defer selected_list.deinit(); + var bindings = std.array_list.Managed(WorktreeStatus.Binding).init(self.allocator); + defer bindings.deinit(); + if (self.model.graph) |graph| for (graph.nodes.items) |bound| { + if (bound.worktree_path.len != 0) bindings.append(.{ .path = bound.worktree_path }) catch {}; + }; + const removed = WorktreeStatus.reclaimSelectedWithPolicy( + self.allocator, path, selected_list.items, bindings.items, policy, true, + ) catch |err| { + self.reclaim_confirmation_armed = false; + self.setStatus(switch (err) { + error.GitFailed => "Reclaim failed: git refused a selected worktree", + error.PolicyDisabled => "Reclaim disabled by project worktree policy", + error.ConfirmationRequired => "Reclaim confirmation required", + error.UnsafeSelection => "Reclaim blocked: selected worktree is unsafe", + else => "Reclaim failed", + }); + return; + }; + self.reclaim_confirmation_armed = false; + const message = std.fmt.allocPrint( + self.allocator, + "Reclaimed {d} selected worktrees", + .{removed}, + ) catch { + self.setStatus("Reclaim complete"); + return; + }; + self.replaceStatus(message); + self.inspectWorktreesImpl(false); + } + + pub fn selectWorktreeRow(self: *App, path: []const u8) bool { + const inspection = self.worktree_inspection orelse return false; + if (!envFlag("GRAPHCODE_UIA_GATE")) { + if (self.currentProject()) |project| { + if (!std.mem.eql(u8, project, inspection.project_path)) return false; + } else return false; + } + for (inspection.entries.items) |entry| { + if (!std.mem.eql(u8, entry.path, path)) continue; + if (WorktreeStatus.decision(entry) != .reclaimable) return false; + if (self.worktree_dialog) |*dialog| { + dialog.clearSelection(); + for (dialog.rows.items, 0..) |row, index| { + if (std.mem.eql(u8, row.entry.path, path)) { + _ = dialog.toggle(index); + break; + } + } + } + if (self.selected_worktree_path.len != 0) self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = self.allocator.dupe(u8, path) catch return false; + self.reclaim_confirmation_armed = false; + self.syncAccessibility(); + return true; + } + return false; + } + + pub fn toggleWorktreeRow(self: *App, index: usize) bool { + const dialog = if (self.worktree_dialog) |*value| value else return false; + if (index >= dialog.rows.items.len or + WorktreeStatus.decision(dialog.rows.items[index].entry) != .reclaimable) return false; + _ = dialog.toggle(index); + if (self.selected_worktree_path.len != 0) { + self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + } + for (dialog.rows.items) |row| { + if (!row.selected) continue; + self.selected_worktree_path = self.allocator.dupe(u8, row.entry.path) catch &.{}; + break; + } + self.reclaim_confirmation_armed = false; + self.syncAccessibility(); + return true; + } + + fn applyUiaWorktreeSelection(self: *App, payload: usize, operation: usize) bool { + const dialog = if (self.worktree_dialog) |*value| value else return false; + var target: ?usize = null; + for (dialog.rows.items, 0..) |row, index| { + if (Accessibility.worktreeIdentityPayload(row.entry.path) == payload) { + target = index; + break; + } + } + const index = target orelse return false; + if (WorktreeStatus.decision(dialog.rows.items[index].entry) != .reclaimable) return false; + switch (operation) { + 0 => { + for (dialog.rows.items) |*row| row.selected = false; + dialog.rows.items[index].selected = true; + }, + 1 => dialog.rows.items[index].selected = true, + 2 => dialog.rows.items[index].selected = false, + else => return false, + } + if (self.selected_worktree_path.len != 0) { + self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + } + for (dialog.rows.items) |row| { + if (!row.selected) continue; + self.selected_worktree_path = self.allocator.dupe(u8, row.entry.path) catch &.{}; + break; + } + self.reclaim_confirmation_armed = false; + self.syncAccessibility(); + return true; + } + + fn mutateUiaFixture(self: *App, mutation: usize) void { + if (!envFlag("GRAPHCODE_UIA_GATE")) return; + if (mutation == 6) { + self.showAbout(); + return; + } + if (mutation == 7) { + self.closeCompositeGroup(); + _ = self.model.setSelectedID("11111111-1111-4111-8111-111111111111"); + self.editSelectedNode(); + return; + } + if (mutation == 8) { + self.setIngressError("Folder could not be opened"); + return; + } + if (mutation == 9) { + self.clearIngressError(); + self.model.deinit(); + self.model = GraphModel.Model.init(self.allocator); + self.surface = .overview; + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } + if (mutation == 10) { + const frame = + \\{"version":2,"kind":"event","sequence":50,"event":{"graphChanged":{"project":{"path":"C:\\GraphCode\\empty","name":"Empty project"},"nodes":[],"edges":[]}}} + ; + _ = self.model.updateFromFrame(frame) catch return; + self.surface = .project; + self.layoutEmptyStateControls(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } + if (mutation == 11) { + self.showRemoteProjectInfo("ssh://builder/GraphCode"); + return; + } + if (mutation == 12) { + self.deleteProjectLoops("C:\\GraphCode\\empty"); + return; + } + if (mutation == 13) { + const frame = + \\{"version":2,"kind":"event","sequence":51,"event":{"graphChanged":{"project":{"path":"C:\\GraphCode\\empty","name":"Empty project"},"nodes":[{"id":"edge-source","title":"Planner","state":"idle"},{"id":"edge-target","title":"Builder","state":"idle"}],"edges":[{"id":"edge-delete","from":"edge-source","to":"edge-target","kind":"handoff"}]}}} + ; + _ = self.model.updateFromFrame(frame) catch return; + self.deleteEdge(0); + return; + } + if (mutation == 14) { + const frame = + \\{"version":2,"kind":"event","sequence":52,"event":{"graphChanged":{"id":"uia-jump-graph","project":{"path":"C:\\GraphCode\\jump-fixture","name":"Jump fixture","remote":false},"nodes":[{"id":"jump-cross-project","title":"UIA loop C","loopType":"timeBased","state":"awaitingInput"}],"edges":[]}}} + ; + _ = self.model.updateFromFrame(frame) catch return; + _ = self.model.setSelectedID("11111111-1111-4111-8111-111111111111"); + self.jumpToNode(); + return; + } + if (mutation == 15) { + self.openProductSettings(); + return; + } + const dialog = if (self.worktree_dialog) |*value| value else return; + switch (mutation) { + 1 => { + if (dialog.rows.items.len > 1) + std.mem.swap(WorktreeDialog.Row, &dialog.rows.items[0], &dialog.rows.items[1]); + }, + 2 => { + const target = for (dialog.rows.items, 0..) |row, index| { + if (std.mem.eql(u8, row.entry.path, "C:\\fixture-safe")) break index; + } else return; + _ = dialog.rows.orderedRemove(target); + if (self.selected_worktree_path.len != 0) { + self.allocator.free(self.selected_worktree_path); + self.selected_worktree_path = &.{}; + } + }, + 3 => { + const target = for (dialog.rows.items, 0..) |row, index| { + if (std.mem.eql(u8, row.entry.path, "C:\\fixture-unsafe")) break index; + } else return; + dialog.rows.items[target].entry.dirty = !dialog.rows.items[target].entry.dirty; + }, + 4 => { + var policy = dialog.policy; + policy.allow_reclaim = !policy.allow_reclaim; + dialog.setPolicy(policy); + }, + 5 => { + var policy = dialog.policy; + policy.confirm_each_reclaim = !policy.confirm_each_reclaim; + dialog.setPolicy(policy); + }, + else => return, + } + self.syncAccessibility(); + } + + pub fn saveWorktreePolicy(self: *App, policy: WorktreeStatus.Policy) !void { + const path = self.currentProject() orelse return error.EmptyProjectPath; + try WorktreeStatus.savePolicy(self.allocator, path, policy); + if (self.worktree_dialog) |*dialog| dialog.setPolicy(policy); + self.setStatus("Worktree policy saved"); + } + + fn editWorktreePolicy(self: *App) void { + const project_path = self.currentProject() orelse { + self.setStatus("Open a project before changing project settings"); + return; + }; + if (envFlag("GRAPHCODE_UIA_GATE") and !envFlag("GRAPHCODE_UIA_SHOW_DIALOGS")) { + self.setStatus("Project settings opened"); + return; + } + const initial = if (self.worktree_dialog) |dialog| + dialog.policy + else + WorktreeStatus.loadPolicy(self.allocator, project_path); + const policy = NativeForms.worktreePolicy(self.window.hwnd, self.allocator, initial) catch { + self.setStatus("Unable to open worktree policy editor"); + return; + } orelse { + self.setStatus("Worktree policy edit cancelled"); + return; + }; + self.saveWorktreePolicy(policy) catch { + self.setStatus("Unable to save project settings"); + return; + }; + self.setStatus("Project settings saved"); + } + + fn saveCurrentWorktreePolicy(self: *App) void { + const dialog = self.worktree_dialog orelse { + self.setStatus("Inspect worktrees before saving policy"); + return; + }; + self.saveWorktreePolicy(dialog.policy) catch { + self.setStatus("Unable to save worktree policy"); + return; + }; + } + + fn toggleAllowReclaim(self: *App) void { + if (self.worktree_dialog) |*dialog| { + var policy = dialog.policy; + policy.allow_reclaim = !policy.allow_reclaim; + dialog.setPolicy(policy); + self.setStatus(if (policy.allow_reclaim) "Policy: reclaim enabled" else "Policy: reclaim disabled"); + } + } + + fn toggleConfirmReclaim(self: *App) void { + if (self.worktree_dialog) |*dialog| { + var policy = dialog.policy; + policy.confirm_each_reclaim = !policy.confirm_each_reclaim; + dialog.setPolicy(policy); + self.setStatus(if (policy.confirm_each_reclaim) "Policy: confirmation required" else "Policy: confirmation disabled"); + } + } + + fn revealSelectedWorktree(self: *App) void { + const dialog = self.worktree_dialog orelse { + self.setStatus("Inspect worktrees before revealing a row"); + return; + }; + const args = dialog.revealSelected() catch { + self.setStatus("Select a worktree row before revealing it"); + return; + }; + const parameters = WorktreeStatus.explorerParameters(self.allocator, args.path) catch { + self.setStatus("Unable to prepare Explorer"); + return; + }; + defer self.allocator.free(parameters); + const wide_params_raw = std.unicode.utf8ToUtf16LeAlloc(self.allocator, parameters) catch { + self.setStatus("Unable to encode Explorer path"); + return; + }; + defer self.allocator.free(wide_params_raw); + const wide_params = self.allocator.alloc(u16, wide_params_raw.len + 1) catch { + self.setStatus("Unable to encode Explorer path"); + return; + }; + defer self.allocator.free(wide_params); + @memcpy(wide_params[0..wide_params_raw.len], wide_params_raw); + wide_params[wide_params_raw.len] = 0; + const result = c.ShellExecuteW( + self.window.hwnd, + std.unicode.utf8ToUtf16LeStringLiteral("open").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("explorer.exe").ptr, + wide_params.ptr, + null, + c.SW_SHOWNORMAL, + ); + if (@intFromPtr(result) <= 32) self.setStatus("Unable to open Explorer") else self.setStatus("Opened selected worktree in Explorer"); + } + + fn keepWorktreeOffer(self: *App, path: []const u8) void { + for (self.kept_worktree_paths.items) |kept| if (std.mem.eql(u8, kept, path)) return; + const copy = self.allocator.dupe(u8, path) catch { + self.setStatus("Unable to keep the worktree offer"); + return; + }; + self.kept_worktree_paths.append(copy) catch { + self.allocator.free(copy); + self.setStatus("Unable to keep the worktree offer"); + return; + }; + self.setStatus("Keeping the resolved worktree"); + } + + fn reclaimWorktreeOffer(self: *App, path: []const u8) void { + const graph = self.model.graph orelse return; + if (!graph.project.isLocalFilesystem()) return; + const inspection = self.worktree_inspection orelse return; + const entry = WorktreeStatus.selectedEntry(inspection.entries.items, path) orelse return; + if (WorktreeStatus.decision(entry) != .reclaimable) { + self.setStatus("This worktree is no longer safe to reclaim"); + return; + } + const message = std.fmt.allocPrint( + self.allocator, + "Remove this landed, clean worktree?\n\n{s}\n\nThe branch history remains in git.", + .{path}, + ) catch return; + defer self.allocator.free(message); + if (!GraphContextMenu.confirm(self.window.hwnd, "Reclaim Worktree", message)) return; + var bindings = std.array_list.Managed(WorktreeStatus.Binding).init(self.allocator); + defer bindings.deinit(); + for (graph.nodes.items) |node| { + if (node.worktree_path.len != 0 and !std.mem.eql(u8, node.worktree_path, path)) + bindings.append(.{ .path = node.worktree_path }) catch {}; + } + const selected = [_][]const u8{path}; + _ = WorktreeStatus.reclaimSelectedWithPolicy( + self.allocator, + graph.project.path, + &selected, + bindings.items, + .{ .allow_reclaim = true, .confirm_each_reclaim = false }, + true, + ) catch |err| { + self.setStatus(switch (err) { + error.UnsafeSelection => "This worktree is no longer safe to reclaim", + error.GitFailed => "Git refused to remove the worktree", + else => "Unable to reclaim the worktree", + }); + return; + }; + self.setStatus("Resolved worktree reclaimed"); + self.inspectWorktrees(); + } + + fn moveWorktreeSelection(self: *App, delta: i32) void { + const inspection = self.worktree_inspection orelse return; + if (inspection.entries.items.len == 0) return; + var index: usize = 0; + if (self.selected_worktree_path.len != 0) { + for (inspection.entries.items, 0..) |entry, i| { + if (std.mem.eql(u8, entry.path, self.selected_worktree_path)) { + index = i; + break; + } + } + } + const count = inspection.entries.items.len; + var offset: usize = 0; + while (offset < count) : (offset += 1) { + const next = @mod(@as(i32, @intCast(index)) + delta * @as(i32, @intCast(offset + 1)) + + @as(i32, @intCast(count)), @as(i32, @intCast(count))); + if (WorktreeStatus.decision(inspection.entries.items[@intCast(next)]) == .reclaimable) { + _ = self.selectWorktreeRow(inspection.entries.items[@intCast(next)].path); + self.ensureWorktreeVisible(@intCast(next)); + return; + } + } + } + + fn ensureWorktreeVisible(self: *App, index: usize) void { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const loop_count = if (self.model.graph) |graph| graph.nodes.items.len else 0; + const top = Sidebar.worktreeRowTopForModel(&self.model, loop_count, index) - self.sidebar_scroll; + const bottom = top + 34; + const viewport_top = Tokens.header_height; + const viewport_bottom = client.bottom - Tokens.workspace_height; + if (top < viewport_top) self.sidebar_scroll -= viewport_top - top; + if (bottom > viewport_bottom) self.sidebar_scroll += bottom - viewport_bottom; + self.clampSidebarScroll(); + } + + fn clampSidebarScroll(self: *App) void { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) { + self.sidebar_scroll = 0; + return; + } + const inspection = if (self.worktree_inspection) |*value| value else null; + self.sidebar_scroll = Sidebar.clampScroll( + self.sidebar_scroll, + Sidebar.maxScroll(&self.model, inspection, client.bottom - Tokens.workspace_height, &self.sidebar_state), + ); + } + + fn handleAction(self: *App, action: InputRouter.Action) void { + switch (action) { + .reconnect => { + self.client.reconnect(); + }, + .open_folder => self.openFolder(), + .create_node => self.createNode(), + .open_node => self.openSelectedNode(), + .stop_node => self.stopSelectedNode(), + .send_node => self.sendSelectedNode(), + .edit_node => if (self.selectedEdgeIndex()) |edge| self.editSelectedEdge(edge) else self.editSelectedNode(), + .rename_selected => self.editSelectedNode(), + .delete_selected => if (self.selectedEdgeIndex()) |edge| self.deleteEdge(edge) else self.deleteSelectedNode(), + .create_edge => self.createEdge(), + .jump_next => self.jumpToNode(), + .command_palette => self.jumpToNode(), + .next_identity => self.navigateIdentity(1, false), + .previous_identity => self.navigateIdentity(-1, false), + .quick_chat => self.createQuickChat(), + .rename_quick_chat => self.renameSelectedQuickChat(), + .delete_quick_chat => self.deleteSelectedQuickChat(), + .settings => self.openSettings(), + .product_settings => self.openProductSettings(), + .clone_repository => self.cloneRepository(), + .cancel_clone => self.cancelClone(), + .remote_repository => self.addRemoteRepository(), + .onboarding => { + const initial_backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; + const backend = Onboarding.show(self.window.hwnd, self.allocator, initial_backend) catch { + self.setStatus("Unable to show onboarding"); + return; + }; + self.applyOnboardingBackend(backend); + }, + .cycle_attention => { + self.selectNextAttention(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .inspect_worktrees => self.inspectWorktrees(), + .reclaim_worktrees => self.reclaimWorktrees(), + .reveal_worktree => self.revealSelectedWorktree(), + .edit_worktree_policy => self.editWorktreePolicy(), + .save_worktree_policy => self.saveCurrentWorktreePolicy(), + .worktree_next => self.moveWorktreeSelection(1), + .worktree_previous => self.moveWorktreeSelection(-1), + .focus_terminal_a => if (self.workspace) |workspace| workspace.focus(0), + .focus_terminal_b => if (self.workspace) |workspace| workspace.focus(1), + .select_next => { + self.selectNextNode(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .select_previous => { + const graph = self.model.graph orelse return; + if (graph.nodes.items.len == 0) return; + const current = self.model.selected_index orelse 0; + const previous = if (current == 0) graph.nodes.items.len - 1 else current - 1; + if (!self.model.setSelectedIndex(previous)) return; + _ = self.selectNodeIndex(previous); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .new_tab => if (self.workspace) |workspace| workspace.newTab() catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to create tab"); + }, + .close_tab => if (self.workspace) |workspace| workspace.closeFocusedPane() catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to close tab"); + }, + .split_horizontal => if (self.workspace) |workspace| workspace.splitFocused(.horizontal) catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to split workspace"); + }, + .split_vertical => if (self.workspace) |workspace| workspace.splitFocused(.vertical) catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Unable to split workspace"); + }, + .focus_next_pane => if (self.workspace) |workspace| workspace.focusNextPane(), + .focus_previous_pane => if (self.workspace) |workspace| workspace.focusPreviousPane(), + .select_previous_tab => if (self.workspace) |workspace| workspace.selectPreviousTab(), + .select_next_tab => if (self.workspace) |workspace| workspace.selectNextTab(), + .show_graph => { + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.workspace_controls.apply(.show_graph); + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .toggle_rail => { + self.workspace_controls.apply(.toggle_rail); + self.layoutWorkspace(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + self.setStatus(if (self.workspace_controls.rail_visible) "Workspace rail shown" else "Workspace rail hidden"); + }, + .toggle_panel => { + self.workspace_controls.apply(.toggle_panel); + if (self.workspace_controls.panel_visible) { + self.surface = .workspace; + } else if (self.surface == .workspace) { + self.surface = .project; + } + self.layoutWorkspace(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + self.setStatus(if (self.workspace_controls.panel_visible) "Workspace panel shown" else "Workspace panel hidden"); + }, + .toggle_activity => { + self.workspace_controls.apply(.toggle_activity); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + self.setStatus(if (self.workspace_controls.activity_enabled) "Activity enabled" else "Activity disabled"); + }, + .zoom_out, .zoom_in => { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; + self.canvas.zoomBy( + @divTrunc(bounds.left + bounds.right, 2), + @divTrunc(bounds.top + bounds.bottom, 2), + if (action == .zoom_in) 1.1 else 0.9, + ); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .actual_size => { + self.canvas.actualSize(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .fit_canvas => { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; + const content = GraphCanvas.contentSize(&self.model, self.surface); + self.canvas.fit( + .{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }, + content.width, + content.height, + ); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + }, + .none => {}, + } + } + + fn navigateIdentity(self: *App, offset: isize, attention_only: bool) void { + const graph = self.model.graph orelse return; + if (graph.nodes.items.len == 0) return; + var items: [256]Navigation.Item = undefined; + const count = @min(graph.nodes.items.len, items.len); + for (graph.nodes.items[0..count], 0..) |node, index| { + var attention = false; + for (self.model.attention.items) |candidate| { + if (std.mem.eql(u8, candidate.id, node.id)) { + attention = true; + break; + } + } + items[index] = .{ + .identity = .{ .project_path = graph.project.path, .node_id = node.id }, + .title = node.title, + .attention = attention, + }; + } + const selected = if (attention_only) + self.navigation_cursor.nextAttention(items[0..count]) + else if (offset > 0) + self.navigation_cursor.next(items[0..count]) + else + self.navigation_cursor.previous(items[0..count]); + const item = selected orelse return; + for (graph.nodes.items, 0..) |node, index| { + if (std.mem.eql(u8, node.id, item.identity.node_id)) { + if (!self.selectNodeIndex(index)) return; + self.openSelectedNode(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return; + } + } + } + + fn onWorkspaceKey(context: ?*anyopaque, key: usize, ctrl: bool, shift: bool) callconv(.c) void { + const app: *App = @ptrCast(@alignCast(context.?)); + app.dispatchWorkspaceKey(key, ctrl, shift); + } + + fn dispatchWorkspaceKey(self: *App, key: usize, ctrl: bool, shift: bool) void { + self.handleAction(InputRouter.keyAction(key, ctrl, shift)); + } + + fn layoutWorkspace(self: *App) void { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + if (self.workspace) |workspace| { + const full_workspace = self.surface == .workspace; + const activity_height = if (self.workspace_controls.activity_enabled) Tokens.activity_strip_height else 0; + const panel_height = if (full_workspace) + @max(0, client.bottom - Tokens.header_height - Tokens.loop_bar_height - activity_height) + else if (self.workspace_controls.panel_visible) + Tokens.workspace_height + else + 0; + workspace.resize( + if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + if (full_workspace) Tokens.header_height + Tokens.loop_bar_height else @max(0, client.bottom - panel_height), + @max(0, client.right - (if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0) - + (if (full_workspace) Tokens.loop_detail_width else 0)), + panel_height, + ); + } + } + + fn status(self: *const App) []const u8 { + if (self.status_override.len != 0) return self.status_override; + return self.client.statusText(); + } + + fn connectionFailureVisible(self: *const App) bool { + return self.client.connectionState() == .disconnected or + (envFlag("GRAPHCODE_UIA_GATE") and envFlag("GRAPHCODE_UIA_CONNECTION_FAILURE")); + } + + fn createEmptyStateControls(self: *App) void { + self.empty_open_folder_button = createButton( + self.window.hwnd, + "Open Folder...", + MainWindow.empty_open_folder_id, + ); + self.empty_global_overview_button = createButton( + self.window.hwnd, + "New Loop", + MainWindow.empty_new_loop_id, + ); + self.layoutEmptyStateControls(); + } + + fn layoutEmptyStateControls(self: *App) void { + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const graph = self.model.graph; + const is_quick_chats = self.surface == .quick_chats; + const is_overview = self.surface == .overview; + const is_empty = if (is_quick_chats) + self.model.quick_chats.items.len == 0 + else if (is_overview) + self.model.graphs.items.len == 0 + else if (graph) |value| + value.nodes.items.len == 0 + else + true; + const is_global = if (graph) |value| value.project.isGlobal() else false; + const content_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0; + const content_right = client.right; + const x = content_left + @divTrunc((content_right - content_left) - 220, 2); + const bounds = GraphCanvas.renderBounds(client.right, client.bottom, self.workspace_controls); + const center_offset: i32 = if (!is_quick_chats and !is_overview and graph == null) -70 else -60; + const y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) + center_offset + 106; + if (self.empty_open_folder_button != null) { + _ = c.ShowWindow( + self.empty_open_folder_button, + if (is_empty and !is_quick_chats and (is_overview or graph == null or is_global)) c.SW_SHOW else c.SW_HIDE, + ); + _ = c.SetWindowPos(self.empty_open_folder_button, null, x, y, 220, 32, c.SWP_NOZORDER | c.SWP_NOACTIVATE); + } + if (self.empty_global_overview_button != null) { + setButtonText(self.empty_global_overview_button, if (is_quick_chats) "New Chat" else "New Loop"); + const show_primary = is_quick_chats or is_overview or + (self.surface == .project and graph != null and !is_global); + const primary_x = if (is_empty) x else content_right - 140; + const primary_y = if (is_empty) + y + (if (is_global or is_overview) @as(i32, 42) else @as(i32, 0)) + else + Tokens.header_height + 14; + _ = c.ShowWindow( + self.empty_global_overview_button, + if (show_primary) c.SW_SHOW else c.SW_HIDE, + ); + _ = c.SetWindowPos( + self.empty_global_overview_button, + null, + primary_x, + primary_y, + if (is_empty) 220 else 120, + 32, + c.SWP_NOZORDER | c.SWP_NOACTIVATE, + ); + } + } + + fn createButton(parent: c.HWND, text: []const u8, id: usize) c.HWND { + const raw = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return null; + defer std.heap.c_allocator.free(raw); + const wide = std.heap.c_allocator.allocSentinel(u16, raw.len, 0) catch return null; + defer std.heap.c_allocator.free(wide); + @memcpy(wide[0..raw.len], raw); + return c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, + 0, + 0, + 220, + 32, + parent, + controlId(id), + c.GetModuleHandleW(null), + null, + ); + } + + fn setButtonText(button: c.HWND, text: []const u8) void { + const raw = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return; + defer std.heap.c_allocator.free(raw); + const wide = std.heap.c_allocator.allocSentinel(u16, raw.len, 0) catch return; + defer std.heap.c_allocator.free(wide); + @memcpy(wide[0..raw.len], raw); + _ = c.SetWindowTextW(button, wide.ptr); + } + + fn controlId(value: usize) c.HMENU { + @setRuntimeSafety(false); + return @ptrFromInt(value); + } + + fn updateNativeChrome(self: *App) void { + self.update_lock.lock(); + const update_checking = self.update_thread != null and !self.update_done; + self.update_lock.unlock(); + MainWindow.updateMenu(self.window.hwnd, .{ + .has_project = self.model.graph != null, + .can_worktrees = if (self.model.graph) |graph| graph.project.isLocalFilesystem() else false, + .has_workspace = self.workspace != null and self.model.graph != null, + .has_attention = self.model.attentionCount() != 0, + .can_close_tab = if (self.workspace) |workspace| workspace.tabCount() > 1 else false, + .sidebar_visible = self.workspace_controls.rail_visible, + .workspace_visible = self.workspace_controls.panel_visible, + .activity_visible = self.workspace_controls.activity_enabled, + .update_checking = update_checking, + }); + self.layoutEmptyStateControls(); + } + + fn setStatus(self: *App, value: []const u8) void { + const copy = self.allocator.dupe(u8, value) catch return; + self.replaceStatus(copy); + if (self.accessibility) |*provider| { + self.syncAccessibility(); + provider.announce(self.status_override, if (std.mem.indexOf(u8, value, "failed") != null or + std.mem.indexOf(u8, value, "Unable") != null or + std.mem.indexOf(u8, value, "blocked") != null) .@"error" else .status) catch {}; + } + } + + fn replaceStatus(self: *App, value: []u8) void { + if (self.status_override.len != 0) self.allocator.free(self.status_override); + self.status_override = value; + self.syncAccessibility(); + } + + fn setIngressError(self: *App, value: []const u8) void { + const copy = self.allocator.dupe(u8, value) catch return; + if (self.ingress_error.len != 0) self.allocator.free(self.ingress_error); + self.ingress_error = copy; + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn clearIngressError(self: *App) void { + if (self.ingress_error.len != 0) self.allocator.free(self.ingress_error); + self.ingress_error = &.{}; + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn syncAccessibility(self: *App) void { + const provider = if (self.accessibility) |*value| value else return; + var elements = std.array_list.Managed(Accessibility.DynamicElement).init(self.allocator); + defer elements.deinit(); + var owned_identities = std.array_list.Managed([]u8).init(self.allocator); + defer { + for (owned_identities.items) |value| self.allocator.free(value); + owned_identities.deinit(); + } + var client: c.RECT = undefined; + _ = c.GetClientRect(self.window.hwnd, &client); + const canvas_bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; + const canvas_rect = c.RECT{ + .left = canvas_bounds.left, + .top = canvas_bounds.top, + .right = canvas_bounds.right, + .bottom = canvas_bounds.bottom, + }; + var sidebar_rows = Sidebar.appendRows( + self.allocator, + &self.model, + if (self.worktree_inspection) |*value| value else null, + self.sidebar_scroll, + &self.sidebar_state, + ) catch return; + defer sidebar_rows.deinit(self.allocator); + for (sidebar_rows.items) |row| { + const bounds = c.RECT{ .left = 12, .top = row.top - 3, .right = 232, .bottom = row.top + 23 }; + switch (row.kind) { + .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", "Local Projects", 1, bounds, false, false) catch return, + .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", "Remote Repositories", 1, bounds, false, false) catch return, + .project => { + const project = self.model.recent_projects.items[row.index]; + self.appendAccessibilityElement(&elements, &owned_identities, "project", project.path, project.name, 1, bounds, false, false) catch return; + }, + .open_project => if (row.project_path) |path| if (self.model.graphFor(path)) |graph| { + self.appendAccessibilityElement(&elements, &owned_identities, "open-project", path, graph.project.name, 1, bounds, self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), false) catch return; + const new_bounds = c.RECT{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }; + self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", path, "New Loop", 1, new_bounds, false, false) catch return; + if (row.has_children) { + const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "project-disclosure", + path, + if (self.sidebar_state.isProjectCollapsed(path)) "Expand project" else "Collapse project", + 1, + disclosure_bounds, + false, + false, + ) catch return; + } + }, + .loop => if (row.project_path) |path| if (self.model.graphFor(path)) |graph| { + if (row.index < graph.nodes.items.len) { + const node = graph.nodes.items[row.index]; + const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ path, node.id }) catch return; + defer self.allocator.free(key); + self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, bounds, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, node.id), false) catch return; + if (row.has_children) { + const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "loop-disclosure", + key, + if (self.sidebar_state.isNodeExpanded(node.id)) "Collapse loop children" else "Expand loop children", + 2, + disclosure_bounds, + false, + false, + ) catch return; + } + } + }, + .worktree => if (self.worktree_dialog) |dialog| { + if (row.index < dialog.rows.items.len) { + const worktree = dialog.rows.items[row.index]; + self.appendAccessibilityElement(&elements, &owned_identities, "worktree", worktree.entry.path, worktree.entry.path, 3, bounds, worktree.selected, WorktreeStatus.decision(worktree.entry) == .reclaimable) catch return; + } + }, + .quick_chat_overview => { + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, bounds, self.surface == .quick_chats, false) catch return; + const new_bounds = c.RECT{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-new", "quick-chats", "New Chat", 1, new_bounds, false, false) catch return; + if (self.model.quick_chats.items.len != 0) { + const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "quick-chats-disclosure", + "quick-chats", + if (self.sidebar_state.chats_collapsed) "Expand Quick Chats" else "Collapse Quick Chats", + 1, + disclosure_bounds, + false, + false, + ) catch return; + } + }, + .quick_chat => if (row.index < self.model.quick_chats.items.len) { + const chat = self.model.quick_chats.items[row.index]; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, bounds, false, false) catch return; + }, + else => {}, + } + } + switch (self.surface) { + .project, .workspace => if (self.model.graph) |graph| { + if (self.model.open_composite_id) |parent_id| { + const back_name = std.fmt.allocPrint(self.allocator, "Back to {s}", .{graph.project.name}) catch return; + owned_identities.append(back_name) catch { + self.allocator.free(back_name); + return; + }; + self.appendAccessibilityElement( + &elements, + &owned_identities, + "composite-back", + parent_id, + back_name, + 4, + GraphCanvas.compositeBreadcrumbBounds(canvas_rect), + false, + false, + ) catch return; + } + for (graph.nodes.items, 0..) |node, index| { + const bounds = GraphCanvas.nodeBounds(index, &self.canvas); + const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; + defer self.allocator.free(key); + self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, bounds, self.model.selected_index == index, false) catch return; + } + }, + .overview => for (self.model.graphs.items, 0..) |graph, graph_index| { + for (graph.nodes.items, 0..) |node, node_index| { + const bounds = GraphCanvas.overviewCardBounds(&self.model, graph_index, node_index, canvas_rect, &self.canvas); + const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; + defer self.allocator.free(key); + self.appendAccessibilityElement(&elements, &owned_identities, "overview-card", key, node.title, 4, bounds, false, false) catch return; + } + }, + .quick_chats => for (self.model.quick_chats.items, 0..) |chat, index| { + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-card", chat.id, chat.title, 4, GraphCanvas.quickChatCardBounds(index, canvas_rect, &self.canvas), false, false) catch return; + }, + } + const canvas_alert = if (self.ingress_error.len != 0) + self.ingress_error + else if (self.connectionFailureVisible()) + GraphCanvas.connection_failure_message + else + ""; + if (canvas_alert.len != 0 and self.surface != .workspace) { + const identity = self.allocator.dupe( + u8, + if (self.ingress_error.len != 0) "canvas-alert:ingress" else "canvas-alert:connection", + ) catch return; + owned_identities.append(identity) catch { + self.allocator.free(identity); + return; + }; + const bounds = GraphCanvas.inlineAlertBounds(canvas_rect); + elements.append(.{ + .identity = identity, + .name = canvas_alert, + .parent = 4, + .selected = false, + .eligible = false, + .invokable = false, + .left = bounds.left, + .top = bounds.top, + .right = bounds.right, + .bottom = bounds.bottom, + }) catch return; + } + if (self.worktree_dialog == null) if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS") catch null) |fixture| { + defer self.allocator.free(fixture); + provider.syncStatus(self.status()); + return; + }; + const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; + provider.syncElements(self.status(), elements.items, policy); + } + + fn appendAccessibilityElement( + self: *App, + elements: *std.array_list.Managed(Accessibility.DynamicElement), + owned_identities: *std.array_list.Managed([]u8), + kind: []const u8, + key: []const u8, + name: []const u8, + parent: c_int, + bounds: c.RECT, + selected: bool, + eligible: bool, + ) !void { + const identity = try std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ kind, key }); + errdefer self.allocator.free(identity); + try owned_identities.append(identity); + try elements.append(.{ + .identity = identity, + .name = name, + .parent = parent, + .selected = selected, + .eligible = eligible, + .invokable = parent != 3, + .left = bounds.left, + .top = bounds.top, + .right = bounds.right, + .bottom = bounds.bottom, + }); + } + + fn applyUiaDynamicInvoke(self: *App, payload: usize) bool { + var target: ?UiaDynamicTarget = null; + const static_targets = [_]struct { identity: []const u8, target: UiaDynamicTarget }{ + .{ .identity = "sidebar-section:local", .target = .local_section }, + .{ .identity = "sidebar-section:remote", .target = .remote_section }, + .{ .identity = "quick-chats-header:quick-chats", .target = .quick_chats_header }, + .{ .identity = "quick-chats-disclosure:quick-chats", .target = .quick_chats_disclosure }, + .{ .identity = "quick-chat-new:quick-chats", .target = .new_quick_chat }, + }; + for (static_targets) |candidate| { + if (Accessibility.worktreeIdentityPayload(candidate.identity) == payload) target = candidate.target; + } + for (self.model.recent_projects.items) |project| { + const identity = std.fmt.allocPrint(self.allocator, "project:{s}", .{project.path}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .recent_project = project.path }; + } + } + for (self.model.graphs.items) |graph| { + const project_identity = std.fmt.allocPrint(self.allocator, "open-project:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_identity); + if (Accessibility.worktreeIdentityPayload(project_identity) == payload) { + if (target != null) return false; + target = .{ .open_project = graph.project.path }; + } + const project_new_identity = std.fmt.allocPrint(self.allocator, "project-new-loop:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_new_identity); + if (Accessibility.worktreeIdentityPayload(project_new_identity) == payload) { + if (target != null) return false; + target = .{ .project_new_loop = graph.project.path }; + } + const project_disclosure_identity = std.fmt.allocPrint(self.allocator, "project-disclosure:{s}", .{graph.project.path}) catch return false; + defer self.allocator.free(project_disclosure_identity); + if (Accessibility.worktreeIdentityPayload(project_disclosure_identity) == payload) { + if (target != null) return false; + target = .{ .project_disclosure = graph.project.path }; + } + for (graph.nodes.items, 0..) |node, index| { + const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return false; + defer self.allocator.free(key); + const sidebar_identity = std.fmt.allocPrint(self.allocator, "loop:{s}", .{key}) catch return false; + defer self.allocator.free(sidebar_identity); + const overview_identity = std.fmt.allocPrint(self.allocator, "overview-card:{s}", .{key}) catch return false; + defer self.allocator.free(overview_identity); + const project_card_identity = std.fmt.allocPrint(self.allocator, "project-card:{s}", .{key}) catch return false; + defer self.allocator.free(project_card_identity); + const loop_disclosure_identity = std.fmt.allocPrint(self.allocator, "loop-disclosure:{s}", .{key}) catch return false; + defer self.allocator.free(loop_disclosure_identity); + if (Accessibility.worktreeIdentityPayload(sidebar_identity) == payload or + Accessibility.worktreeIdentityPayload(overview_identity) == payload or + Accessibility.worktreeIdentityPayload(project_card_identity) == payload) + { + if (target != null) return false; + target = .{ .loop = .{ .project_path = graph.project.path, .index = index } }; + } + if (Accessibility.worktreeIdentityPayload(loop_disclosure_identity) == payload) { + if (target != null) return false; + target = .{ .loop_disclosure = .{ .project_path = graph.project.path, .index = index } }; + } + } + if (self.model.isCompositeOpen()) if (self.model.graph) |active_graph| { + if (self.model.open_composite_id) |parent_id| { + const identity = std.fmt.allocPrint(self.allocator, "composite-back:{s}", .{parent_id}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .composite_back; + } + } + for (active_graph.nodes.items, 0..) |node, index| { + const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ active_graph.project.path, node.id }) catch return false; + defer self.allocator.free(key); + const identity = std.fmt.allocPrint(self.allocator, "project-card:{s}", .{key}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .active_loop = index }; + } + } + }; + } + for (self.model.quick_chats.items) |chat| { + const identity = std.fmt.allocPrint(self.allocator, "quick-chat-card:{s}", .{chat.id}) catch return false; + defer self.allocator.free(identity); + if (Accessibility.worktreeIdentityPayload(identity) == payload) { + if (target != null) return false; + target = .{ .quick_chat = chat.id }; + } + const row_identity = std.fmt.allocPrint(self.allocator, "quick-chat-row:{s}", .{chat.id}) catch return false; + defer self.allocator.free(row_identity); + if (Accessibility.worktreeIdentityPayload(row_identity) == payload) { + if (target != null) return false; + target = .{ .quick_chat = chat.id }; + } + } + const resolved = target orelse return false; + switch (resolved) { + .local_section => self.sidebar_state.local_collapsed = !self.sidebar_state.local_collapsed, + .remote_section => self.sidebar_state.remote_collapsed = !self.sidebar_state.remote_collapsed, + .quick_chats_header => { + self.surface = .quick_chats; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + }, + .quick_chats_disclosure => self.sidebar_state.chats_collapsed = !self.sidebar_state.chats_collapsed, + .new_quick_chat => self.createQuickChat(), + .recent_project => |path| self.openProject(path), + .open_project => |path| { + if (self.selectProject(path)) { + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.rebindWorkspace(path); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + }, + .project_new_loop => |path| { + if (self.selectProject(path)) self.createNode(); + }, + .project_disclosure => |path| self.sidebar_state.toggleProject(path) catch return false, + .loop => |loop| self.openLoopFromAccessibility(loop.project_path, loop.index), + .loop_disclosure => |loop| { + const graph = self.model.graphFor(loop.project_path) orelse return false; + if (loop.index >= graph.nodes.items.len) return false; + self.sidebar_state.toggleNode(graph.nodes.items[loop.index].id) catch return false; + if (self.sidebar_store) |*store| store.save(&self.sidebar_state) catch return false; + }, + .active_loop => |index| { + _ = self.selectNodeIndex(index); + self.openSelectedNode(); + }, + .composite_back => self.closeCompositeGroup(), + .quick_chat => |id| { + self.client.sendOpenQuickChat(id); + self.setStatus("Opening quick chat..."); + }, + } + self.clampSidebarScroll(); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + + fn openLoopFromAccessibility(self: *App, project_path: []const u8, index: usize) void { + if (!self.selectProject(project_path)) return; + const graph = self.model.graph orelse return; + if (index >= graph.nodes.items.len) return; + _ = self.selectNodeIndex(index); + if (std.mem.eql(u8, graph.nodes.items[index].loop_type, "composite") or + std.mem.eql(u8, graph.nodes.items[index].loop_type, "proactive")) + { + self.surface = .project; + self.workspace_controls.panel_visible = false; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.showCompositeGroup(graph.nodes.items[index]); + return; + } + self.surface = .workspace; + self.workspace_controls.panel_visible = true; + self.layoutWorkspace(); + self.layoutEmptyStateControls(); + self.clearEdgeSelection(); + self.rebindWorkspace(project_path); + if (self.workspace) |workspace| { + workspace.openNode(0, graph.nodes.items[index].id) catch { + self.setStatus("Unable to open selected loop"); + }; + workspace.focus(0); + } + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn acquireSingleInstance(self: *App) !void { + const user = std.process.getEnvVarOwned(self.allocator, "USERNAME") catch + try std.process.getEnvVarOwned(self.allocator, "USER"); + defer self.allocator.free(user); + const name = try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ instance_prefix, user }); + defer self.allocator.free(name); + const raw_wide = try std.unicode.utf8ToUtf16LeAlloc(self.allocator, name); + defer self.allocator.free(raw_wide); + const wide = try self.allocator.alloc(u16, raw_wide.len + 1); + defer self.allocator.free(wide); + @memcpy(wide[0..raw_wide.len], raw_wide); + wide[raw_wide.len] = 0; + self.instance_mutex = c.CreateMutexW(null, 1, wide.ptr); + if (self.instance_mutex == null) return error.SingleInstanceMutexFailed; + if (c.GetLastError() == c.ERROR_ALREADY_EXISTS) { + _ = c.CloseHandle(self.instance_mutex); + self.instance_mutex = null; + return error.InstanceAlreadyRunning; + } + } +}; + +fn onDaemonFrame( + context: ?*anyopaque, + frame: [*]const u8, + length: usize, +) callconv(.c) void { + const app: *App = @ptrCast(@alignCast(context.?)); + app.onFrame(frame[0..length]); + _ = c.InvalidateRect(app.window.hwnd, null, 0); +} + +fn onContextAction(context: ?*anyopaque, action: GraphContextMenu.Action, target: GraphContextMenu.Target) void { + const app: *App = @ptrCast(@alignCast(context.?)); + app.handleContextAction(action, target); +} + +fn onWindowMessage( + context: ?*anyopaque, + hwnd: c.HWND, + message: c.UINT, + wparam: c.WPARAM, + lparam: c.LPARAM, + result: *c.LRESULT, +) callconv(.c) bool { + const app: *App = @ptrCast(@alignCast(context.?)); + if (TrayModule.taskbar_created != 0 and message == TrayModule.taskbar_created) { + app.tray.readd(); + if (!app.tray.added) app.setStatus("System tray unavailable; retrying"); + result.* = 0; + return true; + } + if (MainWindow.restore_message != 0 and message == MainWindow.restore_message) { + restoreShellWindow(hwnd); + result.* = 0; + return true; + } + if (app.tray_test_hook_enabled and + TrayModule.test_hook_message != 0 and + message == TrayModule.test_hook_message) + { + if (wparam == TrayModule.test_hook_menu) { + result.* = if (app.tray.menu) |menu| @intCast(@intFromPtr(menu)) else 0; + return true; + } + const event: c.UINT = switch (wparam) { + TrayModule.test_hook_open => @intCast(c.WM_LBUTTONDBLCLK), + TrayModule.test_hook_context => @intCast(c.WM_CONTEXTMENU), + else => { + result.* = 0; + return true; + }, + }; + _ = c.PostMessageW( + hwnd, + TrayModule.notify_message, + TrayModule.test_callback_wparam, + TrayModule.testNotificationLParam(event), + ); + result.* = 0; + return true; + } + if (message == TrayModule.notify_message and TrayModule.callbackTargetsIcon(lparam)) { + const event = TrayModule.notificationEvent(lparam); + app.tray.observeTestCallback( + event, + app.tray_test_hook_enabled and wparam == TrayModule.test_callback_wparam, + ); + if (event == c.WM_LBUTTONDBLCLK) { + restoreShellWindow(hwnd); + } else if (event == c.WM_RBUTTONUP or event == c.WM_CONTEXTMENU) { + app.tray.showMenu(); + } + result.* = 0; + return true; + } + switch (message) { + c.WM_GETOBJECT => if (app.accessibility) |*provider| { + const object = provider.getObject(hwnd, wparam, lparam); + if (object != 0) { + result.* = object; + return true; + } + }, + c.WM_INITMENUPOPUP => { + app.updateNativeChrome(); + result.* = 0; + return true; + }, + c.WM_COMMAND => { + if ((wparam & Accessibility.uia_dynamic_invoke_mask) == Accessibility.uia_dynamic_invoke_tag) { + _ = app.applyUiaDynamicInvoke(wparam & Accessibility.uia_row_payload_mask); + result.* = 0; + return true; + } + if ((wparam & Accessibility.uia_selection_command_mask) == Accessibility.uia_selection_command_tag) { + const operation = (wparam & Accessibility.uia_selection_operation_mask) >> + Accessibility.uia_selection_operation_shift; + _ = app.applyUiaWorktreeSelection( + wparam & Accessibility.uia_row_payload_mask, + operation, + ); + result.* = 0; + return true; + } + const command_id: u16 = @truncate(wparam); + if (command_id == @as(u16, @truncate(TrayModule.command_exit))) { + app.exit_requested = true; + app.update_cancel.store(true, .release); + _ = c.EndMenu(); + app.tray.remove(); + c.ExitProcess(0); + } + if (command_id == @as(u16, @truncate(TrayModule.command_open))) { + restoreShellWindow(hwnd); + result.* = 0; + return true; + } + const tray_command = @as(c.WPARAM, @intCast(@as(usize, @bitCast(wparam)) & 0xffff)); + if (tray_command == TrayModule.command_open) { + restoreShellWindow(hwnd); + result.* = 0; + return true; + } + if (tray_command == TrayModule.command_exit) { + app.exit_requested = true; + app.update_cancel.store(true, .release); + _ = c.EndMenu(); + app.tray.remove(); + c.ExitProcess(0); + } + switch (tray_command) { + 6 => app.inspectWorktrees(), + 7 => app.reclaimWorktrees(), + 8 => app.revealSelectedWorktree(), + 9 => app.editWorktreePolicy(), + 10 => app.saveCurrentWorktreePolicy(), + 12 => app.toggleAllowReclaim(), + 13 => app.toggleConfirmReclaim(), + Accessibility.uia_open_overview_command => app.openGlobalOverview(), + Accessibility.uia_open_quick_chats_command => { + app.surface = .quick_chats; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + }, + Accessibility.uia_primary_canvas_action_command => { + if (app.surface == .quick_chats) app.handleAction(.quick_chat) else app.handleAction(.create_node); + }, + Accessibility.uia_zoom_out_command => { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; + app.canvas.zoomBy(@divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2), 0.9); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + }, + Accessibility.uia_actual_size_command => { + app.canvas.actualSize(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + }, + Accessibility.uia_zoom_in_command => { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; + app.canvas.zoomBy(@divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2), 1.1); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + }, + Accessibility.uia_fit_command => { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; + const content = GraphCanvas.contentSize(&app.model, app.surface); + app.canvas.fit( + .{ .left = bounds.left, .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }, + content.width, + content.height, + ); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + }, + else => if (wparam >= 1000 and wparam < 2000) { + _ = app.toggleWorktreeRow(@intCast(wparam - 1000)); + }, + } + const id: usize = @intCast(@as(u16, @truncate(wparam))); + if (id == MainWindow.empty_open_folder_id) { + app.openFolder(); + } else if (id == MainWindow.empty_new_loop_id) { + if (app.surface == .quick_chats) app.handleAction(.quick_chat) else app.handleAction(.create_node); + } else if (MainWindow.commandFromId(id)) |command| { + switch (command) { + .open_folder => app.openFolder(), + .clone_repository => app.handleAction(.clone_repository), + .remote_repository => app.handleAction(.remote_repository), + .new_quick_chat => app.handleAction(.quick_chat), + .open_global_overview => app.openGlobalOverview(), + .worktrees => app.handleAction(.inspect_worktrees), + .reclaim_worktrees => app.handleAction(.reclaim_worktrees), + .reveal_worktree => app.handleAction(.reveal_worktree), + .edit_worktree_policy => app.handleAction(.edit_worktree_policy), + .save_worktree_policy => app.handleAction(.save_worktree_policy), + .exit => { + app.exit_requested = true; + app.update_cancel.store(true, .release); + app.tray.remove(); + c.ExitProcess(0); + }, + .jump_loop => app.handleAction(.jump_next), + .review_attention => app.handleAction(.cycle_attention), + .next_loop => app.handleAction(.select_next), + .previous_loop => app.handleAction(.select_previous), + .create_node => app.handleAction(.create_node), + .create_edge => app.handleAction(.create_edge), + .stop_loop => app.handleAction(.stop_node), + .show_graph => app.handleAction(.show_graph), + .new_tab => app.handleAction(.new_tab), + .close_tab => app.handleAction(.close_tab), + .split_right => app.handleAction(.split_horizontal), + .split_down => app.handleAction(.split_vertical), + .next_tab => app.handleAction(.select_next_tab), + .previous_tab => app.handleAction(.select_previous_tab), + .focus_next_pane => app.handleAction(.focus_next_pane), + .focus_previous_pane => app.handleAction(.focus_previous_pane), + .reconnect => app.handleAction(.reconnect), + .settings => app.handleAction(.settings), + .product_settings => app.handleAction(.product_settings), + .toggle_sidebar => app.handleAction(.toggle_rail), + .toggle_workspace => app.handleAction(.toggle_panel), + .toggle_activity => app.handleAction(.toggle_activity), + .zoom_out => app.handleAction(.zoom_out), + .actual_size => app.handleAction(.actual_size), + .zoom_in => app.handleAction(.zoom_in), + .fit_canvas => app.handleAction(.fit_canvas), + .onboarding => app.handleAction(.onboarding), + .check_updates => app.checkForUpdates(), + .about => app.showAbout(), + } + } + app.updateNativeChrome(); + result.* = 0; + return true; + }, + c.WM_PAINT => { + var paint: c.PAINTSTRUCT = undefined; + const hdc = c.BeginPaint(hwnd, &paint); + const inspection = if (app.worktree_inspection) |*value| value else null; + app.update_lock.lock(); + if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); + const offered_version = if (app.update_state.state == .available) app.update_version else ""; + GraphCanvas.paint(hwnd, hdc, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface); + app.update_lock.unlock(); + if (app.workspace_controls.panel_visible or app.surface == .workspace) { + if (app.surface == .workspace) { + if (workspaceGraph(&app.model)) |graph| { + const index = app.model.selectedIndex() orelse 0; + if (index < graph.nodes.items.len) { + const node = graph.nodes.items[index]; + TerminalWorkspace.Workspace.paintLoopBar( + hdc, + app.allocator, + if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, + clientRight(hwnd) - Tokens.loop_detail_width, + graph.project.name, + node.title, + node.loop_type, + node.state, + node.activity, + isResolvedLoopState(node.state), + ); + } + } + } + if (app.workspace) |workspace| workspace.paintChrome(hdc); + if (app.surface == .workspace) { + if (workspaceGraph(&app.model)) |graph| { + const index = app.model.selectedIndex() orelse graph.nodes.items.len; + GraphCanvas.paintLoopDetailRail( + hdc, + app.allocator, + graph, + index, + clientRight(hwnd), + clientBottom(hwnd), + ); + } + } + } + _ = c.EndPaint(hwnd, &paint); + result.* = 0; + return true; + }, + c.WM_SIZE => { + app.layoutWorkspace(); + app.clampSidebarScroll(); + app.layoutEmptyStateControls(); + app.syncAccessibility(); + result.* = 0; + return true; + }, + c.WM_TIMER => if (wparam == MainWindow.timer_id) { + app.smoke_tick += 1; + if (!app.tray.added and app.smoke_tick % 10 == 0) { + app.tray.add(hwnd) catch app.setStatus("System tray unavailable; retrying"); + } + app.finishUpdateCheck(); + if (app.clone_operation) |operation| { + var progress: [256]u8 = undefined; + var recent_stderr: [256]u8 = undefined; + const output = operation.snapshot(&progress, &recent_stderr); + if (output.progress_len != 0) { + app.setStatus(progress[0..output.progress_len]); + } else if (output.stderr_len != 0) { + app.setStatus(recent_stderr[0..output.stderr_len]); + } + if (operation.poll()) |status| { + operation.deinit(); + app.clone_operation = null; + app.setStatus(switch (status) { + .finished => "Clone complete", + .cancelled => "Clone cancelled; partial output removed", + else => "Clone failed; partial output removed", + }); + } + } + app.client.poll(); + const connection_state = app.client.connectionState(); + if (connection_state == .connected) app.flushPendingProject(); + if (app.client.connectionState() == .connected) { + if (app.open_project_pending and + (app.pending_rebind_path.len != 0 or app.last_project_opened.len != 0)) + { + app.sendPendingOpen(); + } else if (!app.sync_requested) { + app.sync_requested = true; + app.client.sendListProjects(); + if (!app.quick_chats_requested) { + app.quick_chats_requested = true; + app.client.sendListQuickChats(); + } + } else if (!app.restore_requested) { + app.restore_requested = true; + app.client.sendRestoreOpenProjects(); + } + app.updateNativeChrome(); + } + const updated_connection_state = app.client.connectionState(); + if (updated_connection_state != app.last_connection_state) { + app.last_connection_state = updated_connection_state; + app.sync_requested = false; + app.restore_requested = false; + app.quick_chats_requested = false; + } + if (app.client.isIdle()) app.smoke_idle_ticks += 1 else app.smoke_idle_ticks = 0; + if (app.workspace) |workspace| { + workspace.poll(); + if (!app.smoke_workspace_restart_observed) { + if (app.smoke_restart_index) |index| { + if (workspace.surfaceIdentityReady(index, app.smoke_restart_session, workspace.projectPath())) { + app.smoke_workspace_restart_observed = true; + app.allocator.free(app.smoke_restart_session); + app.smoke_restart_session = &.{}; + app.smoke_restart_index = null; + } + } + } + if (workspace.inputStatus()) |input_message| app.setStatus(input_message); + } + if (app.smoke and app.smoke_tick == 8) { + app.refreshWorkspace(); + } + if (app.smoke and app.smoke_tick >= 12 and !app.smoke_workspace_actions_ran) { + runSmokeWorkspaceActions(app); + } + if (app.smoke and app.smoke_tick == 16 and + app.client.connectionState() == .connected and !app.smoke_action_requested) + { + app.smoke_action_requested = true; + app.sendSelectedNode(); + } + if (app.smoke and app.smoke_tick == 16 and !app.smoke_input_requested and + envFlag("GRAPHCODE_SHELL_LARGE_PASTE")) + { + app.smoke_input_requested = true; + if (app.workspace) |workspace| { + const paste = app.allocator.alloc(u8, 1024 * 1024) catch { + app.setStatus("Large paste allocation failed"); + return true; + }; + @memset(paste, 'x'); + workspace.send(paste); + app.allocator.free(paste); + } + } + if (app.smoke and app.smoke_tick >= 12 and + ((app.stress and app.smoke_tick % 2 == 0) or + (!app.stress and app.smoke_tick == 12)) and + !envFlag("GRAPHCODE_SHELL_WORKSPACE_ACTIONS")) + { + if (app.workspace) |workspace| { + if (workspace.hasSurface(0)) { + workspace.recreate(0) catch { + app.setStatus("Terminal recreate failed"); + }; + } + } + } + const smoke_deadline: usize = if (app.stress) 96 else 52; + if (app.smoke and + ((app.stress and app.smoke_tick >= 56) or + (!app.stress and app.smoke_tick >= 32)) and + (app.smoke_idle_ticks >= 5 or + app.smoke_tick >= smoke_deadline)) + { + if (app.require_smoke_contract and !smokeContractPassed(app)) { + app.smoke_failure = true; + } + app.exit_requested = true; + _ = c.DestroyWindow(hwnd); + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + }, + MainWindow.wm_app_tick => { + app.client.reconnect(); + result.* = 0; + return true; + }, + MainWindow.wm_uia_fixture_mutate => { + app.mutateUiaFixture(wparam); + result.* = 0; + return true; + }, + c.WM_KEYDOWN => { + if (wparam == c.VK_ESCAPE) { + app.cancelCanvasInteraction(); + result.* = 0; + return true; + } + const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; + const shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0; + app.handleAction(InputRouter.keyAction(wparam, ctrl, shift)); + app.updateNativeChrome(); + result.* = 0; + return true; + }, + c.WM_CAPTURECHANGED, c.WM_CANCELMODE => { + app.cancelCanvasInteraction(); + result.* = 0; + return true; + }, + c.WM_ACTIVATEAPP => { + if (wparam == 0) app.cancelCanvasInteraction(); + result.* = 0; + return true; + }, + c.WM_LBUTTONDOWN => { + const x = mouseX(lparam); + const y = mouseY(lparam); + if (envFlag("GRAPHCODE_UIA_GATE") and x == 0 and y == 0) { + _ = app.toggleWorktreeRow(0); + result.* = 0; + return true; + } + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + if (GraphCanvas.headerActionAt( + x, + y, + client.right, + app.model.attentionCount() != 0, + app.worktree_inspection != null, + app.model.currentGraph() != null, + )) |action| { + switch (action) { + .review_attention => app.handleAction(.cycle_attention), + .inspect_worktrees => app.inspectWorktrees(), + .jump => app.handleAction(.jump_next), + .toggle_panel => app.handleAction(.toggle_panel), + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + const routing = inputBounds(client.right, client.bottom, app.workspace_controls); + const workspace_top = if (app.surface == .workspace and app.workspace_controls.panel_visible) + Tokens.header_height + else + routing.workspace_top; + const rail_left = routing.rail_left; + if ((app.workspace_controls.panel_visible or app.surface == .workspace) and x >= rail_left and y >= workspace_top) { + if (app.surface == .workspace) { + if (workspaceGraph(&app.model)) |graph| { + const index = app.model.selectedIndex() orelse graph.nodes.items.len; + if (index < graph.nodes.items.len) { + const node = graph.nodes.items[index]; + if (TerminalWorkspace.loopBarActionAt( + rail_left, + Tokens.header_height, + client.right - Tokens.loop_detail_width, + x, + y, + isResolvedLoopState(node.state), + )) |action| { + switch (action) { + .stop => app.stopSelectedNode(), + .show_graph => app.handleAction(.show_graph), + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + } + } + } + if (app.workspace) |workspace| { + if (workspace.chromeActionAt(x, y)) |action| { + app.handleAction(switch (action) { + .new_tab => .new_tab, + .split_right => .split_horizontal, + .split_down => .split_vertical, + }); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (workspace.selectTabAt(x, y)) { + result.* = 0; + return true; + } + } + } + if (x >= rail_left and y < workspace_top) { + const bounds = c.RECT{ .left = rail_left, .top = Tokens.header_height, .right = client.right, .bottom = workspace_top }; + if (app.surface != .workspace) { + if (GraphCanvas.hitTestZoomControl(x, y, bounds)) |control| { + const center_x = @divTrunc(bounds.left + bounds.right, 2); + const center_y = @divTrunc(bounds.top + bounds.bottom, 2); + switch (control) { + .out => app.canvas.zoomBy(center_x, center_y, 0.9), + .actual => app.canvas.actualSize(), + .in => app.canvas.zoomBy(center_x, center_y, 1.1), + .fit => { + const content = GraphCanvas.contentSize(&app.model, app.surface); + app.canvas.fit(bounds, content.width, content.height); + }, + } + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + } + switch (app.surface) { + .overview => { + if (GraphCanvas.hitTestOverview(&app.model, x, y, &app.canvas, bounds)) |hit| { + const graph = app.model.graphs.items[hit.graph_index]; + if (app.selectProject(graph.project.path)) { + app.surface = .workspace; + app.workspace_controls.panel_visible = true; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.clearEdgeSelection(); + app.rebindWorkspace(graph.project.path); + _ = app.selectNodeIndex(hit.node_index); + if (app.workspace) |workspace| { + workspace.openNode(0, graph.nodes.items[hit.node_index].id) catch { + app.setStatus("Unable to open selected loop"); + }; + workspace.focus(0); + } + } + } else { + app.canvas.beginPan(x, y); + _ = c.SetCapture(hwnd); + } + _ = c.InvalidateRect(hwnd, null, 0); + }, + .quick_chats => { + if (GraphCanvas.hitTestQuickChat(app.model.quick_chats.items.len, x, y, &app.canvas, bounds)) |index| { + app.client.sendOpenQuickChat(app.model.quick_chats.items[index].id); + app.setStatus("Opening quick chat..."); + } else { + app.canvas.beginPan(x, y); + _ = c.SetCapture(hwnd); + } + _ = c.InvalidateRect(hwnd, null, 0); + }, + .project, .workspace => if (app.model.graph) |graph| { + if (GraphCanvas.hitTestCompositeBack(&app.model, x, y, bounds)) { + app.closeCompositeGroup(); + } else if (GraphCanvas.hitTestReclaimOffer( + graph.nodes.items, + if (app.worktree_inspection) |*value| value else null, + app.kept_worktree_paths.items, + x, + y, + &app.canvas, + )) |hit| { + const path = graph.nodes.items[hit.node_index].worktree_path; + switch (hit.action) { + .reclaim => app.reclaimWorktreeOffer(path), + .keep => app.keepWorktreeOffer(path), + } + _ = c.InvalidateRect(hwnd, null, 0); + } else if (GraphCanvas.hitTestConnector(graph.nodes.items, x, y, &app.canvas, bounds)) |index| { + if (app.edge_drag_source_id.len != 0) app.allocator.free(app.edge_drag_source_id); + app.edge_drag_source_id = app.allocator.dupe(u8, graph.nodes.items[index].id) catch &.{}; + if (app.edge_drag_source_id.len != 0) { + app.canvas.beginEdgeDrag(app.edge_drag_source_id, x, y); + _ = c.SetCapture(hwnd); + } + } else if (GraphCanvas.hitTest(graph.nodes.items, x, y, &app.canvas, bounds)) |index| { + _ = app.selectNodeIndex(index); + app.canvas.beginNodeDrag(graph.nodes.items[index].id, index, x, y); + _ = c.SetCapture(hwnd); + _ = c.InvalidateRect(hwnd, null, 0); + } else if (GraphCanvas.hitTestEdge(graph.nodes.items, graph.edges.items, x, y, &app.canvas, bounds)) |index| { + _ = app.selectEdgeIndex(index); + _ = c.InvalidateRect(hwnd, null, 0); + } else { + app.clearSelection(); + app.canvas.beginPan(x, y); + _ = c.SetCapture(hwnd); + } + } else { + app.canvas.beginPan(x, y); + _ = c.SetCapture(hwnd); + }, + } + result.* = 0; + return true; + } + if (app.workspace_controls.rail_visible) { + app.update_lock.lock(); + const update_available = app.update_state.state == .available; + app.update_lock.unlock(); + if (Sidebar.updateBannerAt(x, y, routing.canvas.bottom, update_available, app.ingress_error.len != 0)) { + app.showCurrentUpdateOffer(); + result.* = 0; + return true; + } + if (Sidebar.rowAt( + x, y, &app.model, if (app.worktree_inspection) |*value| value else null, + app.sidebar_scroll, workspace_top, &app.sidebar_state, + )) |row| { + const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; + switch (row.kind) { + .local_heading => app.sidebar_state.local_collapsed = !app.sidebar_state.local_collapsed, + .remote_heading => app.sidebar_state.remote_collapsed = !app.sidebar_state.remote_collapsed, + .project => app.openProject(app.model.recent_projects.items[row.index].path), + .open_project => if (row.project_path) |path| { + if (x >= 198 and row.has_children) { + app.sidebar_state.toggleProject(path) catch app.setStatus("Sidebar state could not be updated"); + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (x >= 174 and x < 198) { + if (app.selectProject(path)) app.createNode(); + result.* = 0; + return true; + } + if (app.selectProject(path)) { + app.surface = .project; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.clearEdgeSelection(); + app.rebindWorkspace(path); + } + }, + .overview => app.openGlobalOverview(), + .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { + if (row.index < graph.nodes.items.len) { + if (x >= 198 and row.has_children) { + app.sidebar_state.toggleNode(graph.nodes.items[row.index].id) catch app.setStatus("Sidebar state could not be updated"); + if (app.sidebar_store) |*store| store.save(&app.sidebar_state) catch app.setStatus("Sidebar expansion could not be saved"); + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (!app.selectProject(path)) return true; + app.surface = .workspace; + app.workspace_controls.panel_visible = true; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + app.clearEdgeSelection(); + app.rebindWorkspace(path); + const selected_graph = app.model.graph orelse return true; + if (row.index >= selected_graph.nodes.items.len) return true; + _ = app.selectNodeIndex(row.index); + if (app.workspace) |workspace| { + workspace.openNode(0, selected_graph.nodes.items[row.index].id) catch { + app.setStatus("Unable to open selected loop"); + }; + workspace.focus(0); + } + } + }, + .worktree => if (app.worktree_inspection) |inspection| { + if (ctrl) { + _ = app.toggleWorktreeRow(row.index); + } else { + _ = app.selectWorktreeRow(inspection.entries.items[row.index].path); + } + app.ensureWorktreeVisible(row.index); + }, + .quick_chat_overview => { + if (x >= 198 and app.model.quick_chats.items.len != 0) { + app.sidebar_state.chats_collapsed = !app.sidebar_state.chats_collapsed; + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (x >= 174 and x < 198) { + app.createQuickChat(); + result.* = 0; + return true; + } + app.surface = .quick_chats; + app.workspace_controls.panel_visible = false; + app.layoutWorkspace(); + app.layoutEmptyStateControls(); + }, + .quick_chat => if (row.index < app.model.quick_chats.items.len) { + app.client.sendOpenQuickChat(app.model.quick_chats.items[row.index].id); + app.setStatus("Opening quick chat..."); + }, + } + app.clampSidebarScroll(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + } + result.* = 0; + return true; + }, + c.WM_RBUTTONUP => { + const point = CanvasInput.decodeMouseMessage(lparam); + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const routing = inputBounds(client.right, client.bottom, app.workspace_controls); + if (app.workspace_controls.rail_visible and point.x < routing.rail_left) { + const inspection = if (app.worktree_inspection) |*value| value else null; + if (Sidebar.rowAt(point.x, point.y, &app.model, inspection, app.sidebar_scroll, routing.canvas.bottom, &app.sidebar_state)) |row| { + var project_path: ?[]const u8 = null; + var remote = false; + switch (row.kind) { + .project => if (row.index < app.model.recent_projects.items.len) { + const project = app.model.recent_projects.items[row.index]; + project_path = project.path; + remote = project.isRemote(); + }, + .open_project => if (row.index < app.model.graphs.items.len) { + const project = app.model.graphs.items[row.index].project; + project_path = project.path; + remote = project.isRemote(); + }, + else => {}, + } + if (project_path) |path| { + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + GraphContextMenu.show( + hwnd, + .{ .project = .{ .path = path, .remote = remote } }, + screen.x, + screen.y, + app, + &onContextAction, + ); + } else switch (row.kind) { + .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { + if (row.index < graph.nodes.items.len) { + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + GraphContextMenu.show( + hwnd, + .{ .node = .{ + .project_path = path, + .id = graph.nodes.items[row.index].id, + .composite = std.mem.eql(u8, graph.nodes.items[row.index].loop_type, "composite") or + std.mem.eql(u8, graph.nodes.items[row.index].loop_type, "proactive"), + .can_arm = std.mem.eql(u8, graph.nodes.items[row.index].pilot_state, "piloted"), + } }, + screen.x, + screen.y, + app, + &onContextAction, + ); + } + }, + .quick_chat => if (row.index < app.model.quick_chats.items.len) { + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + GraphContextMenu.show( + hwnd, + .{ .quick_chat = .{ .id = app.model.quick_chats.items[row.index].id } }, + screen.x, + screen.y, + app, + &onContextAction, + ); + }, + .quick_chat_overview => { + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + GraphContextMenu.show(hwnd, .quick_chats, screen.x, screen.y, app, &onContextAction); + }, + else => {}, + } + } + result.* = 0; + return true; + } + if (point.x >= routing.canvas.left and point.y >= routing.canvas.top and point.y < routing.canvas.bottom) { + const bounds = c.RECT{ .left = routing.canvas.left, .top = routing.canvas.top, .right = routing.canvas.right, .bottom = routing.canvas.bottom }; + if (app.surface == .quick_chats) { + if (GraphCanvas.hitTestQuickChat(app.model.quick_chats.items.len, point.x, point.y, &app.canvas, bounds)) |index| { + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + app.showQuickChatContextMenu(index, screen.x, screen.y); + } + result.* = 0; + return true; + } + if (app.surface != .project) { + result.* = 0; + return true; + } + var target: enum { background, node, edge } = .background; + var target_index: usize = 0; + if (app.model.graph) |graph| { + if (GraphCanvas.hitTest(graph.nodes.items, point.x, point.y, &app.canvas, bounds)) |index| { + target = .node; + target_index = index; + } else if (GraphCanvas.hitTestEdge(graph.nodes.items, graph.edges.items, point.x, point.y, &app.canvas, bounds)) |index| { + target = .edge; + target_index = index; + } + } + var screen = c.POINT{ .x = point.x, .y = point.y }; + _ = c.ClientToScreen(hwnd, &screen); + switch (target) { + .background => GraphContextMenu.show(hwnd, .background, screen.x, screen.y, app, &onContextAction), + .node => app.showNodeContextMenu(target_index, screen.x, screen.y), + .edge => app.showEdgeContextMenu(target_index, screen.x, screen.y), + } + result.* = 0; + return true; + } + result.* = 0; + return true; + }, + c.WM_LBUTTONUP => { + if (app.canvas.node_dragging) { + app.canvas.endNodeDrag(); + if (app.canvas_layout_store) |*store| { + store.save(&app.canvas) catch app.setStatus("Canvas position could not be saved"); + } + _ = c.ReleaseCapture(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (app.canvas.edge_dragging) { + const point = CanvasInput.decodeMouseMessage(lparam); + const source_id = app.copyEdgeDragSourceForDrop() orelse { + app.cancelCanvasInteraction(); + result.* = 0; + return true; + }; + defer app.allocator.free(source_id); + _ = c.ReleaseCapture(); + if (app.model.graph) |graph| { + const bounds = c.RECT{ .left = Tokens.sidebar_width, .top = Tokens.header_height, .right = clientRight(hwnd), .bottom = clientBottom(hwnd) - Tokens.workspace_height }; + if (GraphModel.findNodeIndexByID(graph.nodes.items, source_id)) |source| { + if (GraphCanvas.hitTest(graph.nodes.items, point.x, point.y, &app.canvas, bounds)) |target| { + if (target != source) app.createEdgeBetweenIDs(source_id, graph.nodes.items[target].id); + } + } + } + if (app.edge_drag_source_id.len != 0) { + app.allocator.free(app.edge_drag_source_id); + app.edge_drag_source_id = &.{}; + } + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + if (app.canvas.dragging) { + app.canvas.endPan(); + _ = c.ReleaseCapture(); + app.syncAccessibility(); + } + result.* = 0; + return true; + }, + c.WM_MOUSEMOVE => { + const hover_y = mouseY(lparam); + const next_hover = if (mouseX(lparam) >= 0 and mouseX(lparam) < Tokens.sidebar_width) hover_y else -1; + if (next_hover != app.sidebar_hover_y) { + app.sidebar_hover_y = next_hover; + _ = c.InvalidateRect(hwnd, null, 0); + } + if (app.canvas.node_dragging) { + app.canvas.updateNodeDrag(mouseX(lparam), mouseY(lparam)); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } else if (app.canvas.edge_dragging) { + app.canvas.updateEdgeDrag(mouseX(lparam), mouseY(lparam)); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } else if (app.canvas.dragging) { + app.canvas.updatePan(mouseX(lparam), mouseY(lparam)); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + } + }, + c.WM_MOUSEWHEEL => { + const wheel = CanvasInput.decodeWheelMessage(lparam, wparam); + const screen_point = c.POINT{ .x = wheel.point.x, .y = wheel.point.y }; + const mapped = CanvasInput.screenToClient(hwnd, screen_point) orelse { + result.* = 0; + return true; + }; + const x = mapped.x; + const y = mapped.y; + const delta = wheel.delta; + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const routing = inputBounds(client.right, client.bottom, app.workspace_controls); + switch (wheelRegion(x, y, routing, app.workspace_controls)) { + .sidebar => { + app.sidebar_scroll = Sidebar.clampScroll(app.sidebar_scroll - @divTrunc(@as(i32, delta), 4), Sidebar.maxScroll(&app.model, if (app.worktree_inspection) |*value| value else null, routing.canvas.bottom, &app.sidebar_state)); + }, + .canvas => app.canvas.zoomAt(x, y, delta), + .none => {}, + } + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + }, + c.WM_SETFOCUS => { + if (app.workspace) |workspace| workspace.focus(workspace.active_surface); + result.* = 0; + return true; + }, + c.WM_CLOSE => { + if (app.exit_requested) { + _ = c.DestroyWindow(hwnd); + } else { + hideShellWindow(hwnd); + } + result.* = 0; + return true; + }, + c.WM_SYSCOMMAND => if ((wparam & 0xfff0) == c.SC_CLOSE) { + if (app.exit_requested) { + _ = c.DestroyWindow(hwnd); + } else { + hideShellWindow(hwnd); + } + result.* = 0; + return true; + }, + c.WM_DESTROY => { + if (app.accessibility) |*provider| provider.detach(); + app.running = false; + _ = c.KillTimer(hwnd, MainWindow.timer_id); + app.tray.remove(); + c.PostQuitMessage(0); + result.* = 0; + return true; + }, + else => {}, + } + + return false; +} + +fn hideShellWindow(hwnd: c.HWND) void { + _ = c.ShowWindow(hwnd, c.SW_HIDE); + _ = c.SetWindowPos( + hwnd, + null, + 0, + 0, + 0, + 0, + c.SWP_NOMOVE | c.SWP_NOSIZE | c.SWP_NOZORDER | c.SWP_NOACTIVATE | c.SWP_HIDEWINDOW, + ); +} + +fn restoreShellWindow(hwnd: c.HWND) void { + _ = c.ShowWindow(hwnd, c.SW_RESTORE); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.BringWindowToTop(hwnd); + if (c.SetForegroundWindow(hwnd) == 0) { + const foreground = c.GetForegroundWindow(); + if (foreground != null and foreground != hwnd) { + const current_thread = c.GetCurrentThreadId(); + const foreground_thread = c.GetWindowThreadProcessId(foreground, null); + if (foreground_thread != 0 and foreground_thread != current_thread and + c.AttachThreadInput(current_thread, foreground_thread, 1) != 0) + { + defer _ = c.AttachThreadInput(current_thread, foreground_thread, 0); + _ = c.BringWindowToTop(hwnd); + _ = c.SetForegroundWindow(hwnd); + } + } + } + _ = c.SetFocus(hwnd); +} + +test "input routing bounds follow hidden workspace panel and rail" { + const shown = inputBounds(1200, 900, .{}); + try std.testing.expectEqual(@as(i32, Tokens.sidebar_width), shown.rail_left); + try std.testing.expectEqual(@as(i32, 900 - Tokens.workspace_height), shown.workspace_top); + try std.testing.expectEqual(shown.rail_left, shown.canvas.left); + try std.testing.expectEqual(WheelRegion.sidebar, wheelRegion(20, 300, shown, .{})); + try std.testing.expectEqual(WheelRegion.none, wheelRegion(20, 850, shown, .{})); + + const hidden_controls = WorkspaceControls.State{ + .rail_visible = false, + .panel_visible = false, + .activity_enabled = false, + }; + const hidden = inputBounds(1200, 900, hidden_controls); + try std.testing.expectEqual(@as(i32, 0), hidden.rail_left); + try std.testing.expectEqual(@as(i32, 900), hidden.workspace_top); + try std.testing.expectEqual(hidden.rail_left, hidden.canvas.left); + try std.testing.expect(hidden.canvas.bottom > shown.canvas.bottom); + try std.testing.expectEqual(WheelRegion.canvas, wheelRegion(20, 300, hidden, hidden_controls)); + try std.testing.expectEqual(WheelRegion.canvas, wheelRegion(600, 850, hidden, hidden_controls)); +} + +test "jump matching ranks exact results across projects" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"loop-a","title":"Fix authentication","state":"running"}],"edges":[]}}} + ); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"Beta"},"nodes":[{"id":"loop-b","title":"Authentication audit","state":"idle"},{"id":"auth","title":"Unrelated","state":"idle"}],"edges":[]}}} + ); + + const exact_id = findJumpMatch(&model, "AUTH").?; + try std.testing.expectEqual(@as(usize, 1), exact_id.project_index); + try std.testing.expectEqual(@as(usize, 1), exact_id.node_index); + try std.testing.expectEqual(@as(u8, 0), exact_id.score); + + const prefix = findJumpMatch(&model, "authentication").?; + try std.testing.expectEqual(@as(usize, 1), prefix.project_index); + try std.testing.expectEqual(@as(usize, 0), prefix.node_index); + try std.testing.expectEqual(@as(u8, 2), prefix.score); +} + +fn runSmokeWorkspaceActions(self: *App) void { + const script = self.smoke_workspace_actions; + if (script.len == 0) return; + const workspace = if (self.workspace) |value| value else return; + if (workspace.firstLiveSurface() == null) return; + const default_script = "create,split,select,focus,close,restart"; + const actions = if (std.mem.eql(u8, script, "1")) default_script else script; + var iterator = std.mem.splitScalar(u8, actions, ','); + self.smoke_workspace_action_failed = false; + const initial_tabs = workspace.tabCount(); + const initial_panes = workspacePaneCount(workspace); + while (iterator.next()) |raw| { + const action = std.mem.trim(u8, raw, " \t\r\n"); + if (std.mem.eql(u8, action, "create") or std.mem.eql(u8, action, "tab") or std.mem.eql(u8, action, "new")) { + const before = workspace.tabCount(); + self.handleAction(.new_tab); + self.smoke_workspace_create_observed = !self.smoke_workspace_action_failed and + workspace.tabCount() == before + 1; + } else if (std.mem.eql(u8, action, "split") or std.mem.eql(u8, action, "split-horizontal")) { + const before = workspacePaneCount(workspace); + self.handleAction(.split_horizontal); + self.smoke_workspace_split_observed = !self.smoke_workspace_action_failed and + workspacePaneCount(workspace) == before + 1; + } else if (std.mem.eql(u8, action, "split-vertical")) { + const before = workspacePaneCount(workspace); + self.handleAction(.split_vertical); + self.smoke_workspace_split_observed = !self.smoke_workspace_action_failed and + workspacePaneCount(workspace) == before + 1; + } else if (std.mem.eql(u8, action, "select")) { + const before = workspace.layout.selected_tab; + workspace.dispatchKeyForTest(0x22, true, false); + self.smoke_workspace_select_observed = workspace.layout.selected_tab != before; + } else if (std.mem.eql(u8, action, "focus")) { + if (workspace.layout.selected()) |tab| { + if (tab.panes.items.len < 2 and workspace.tabCount() > 1) + self.handleAction(.select_previous_tab); + } + const before = workspace.active_surface; + workspace.dispatchKeyForTest(0xDD, true, false); + self.smoke_workspace_focus_observed = workspace.active_surface != before; + } else if (std.mem.eql(u8, action, "close")) { + const before = workspacePaneCount(workspace); + self.handleAction(.close_tab); + self.smoke_workspace_close_observed = workspacePaneCount(workspace) + 1 == before and + !self.smoke_workspace_action_failed; + } else if (std.mem.eql(u8, action, "restart")) { + const index = workspace.firstLiveSurface() orelse { + self.smoke_workspace_action_failed = true; + self.setStatus("Smoke workspace restart has no live surface"); + continue; + }; + const before_tabs = workspace.tabCount(); + const before_panes = workspacePaneCount(workspace); + const before_selected = workspace.layout.selected_tab; + const session = self.allocator.dupe(u8, workspace.surfaces[index].session_name) catch { + self.smoke_workspace_action_failed = true; + continue; + }; + if (!workspace.hasSurface(index) and !workspace.hasAttach(index)) { + self.smoke_workspace_action_failed = true; + self.setStatus("Smoke workspace restart has no live slot"); + } else { + workspace.recreate(index) catch { + self.smoke_workspace_action_failed = true; + self.setStatus("Smoke workspace restart failed"); + }; + self.smoke_workspace_restart_observed = false; + self.smoke_restart_index = index; + self.smoke_restart_session = session; + if (workspace.tabCount() != before_tabs or + workspacePaneCount(workspace) != before_panes or + workspace.layout.selected_tab != before_selected) + { + self.smoke_workspace_action_failed = true; + } + } + } + } + if (workspace.tabCount() < initial_tabs or workspacePaneCount(workspace) < initial_panes) + self.smoke_workspace_action_failed = true; + self.refreshWorkspace(); + self.smoke_workspace_actions_ran = true; +} + +fn workspacePaneCount(workspace: anytype) usize { + var count: usize = 0; + for (workspace.layout.tabs.items) |tab| count += tab.panes.items.len; + return count; +} + +fn smokeContractPassed(self: *const App) bool { + const scripted_actions = self.smoke_workspace_actions_ran; + if (!scripted_actions and self.client.connectionState() != .connected) return false; + if (scripted_actions) { + return !self.smoke_workspace_action_failed and + self.smoke_workspace_create_observed and + self.smoke_workspace_split_observed and + self.smoke_workspace_select_observed and + self.smoke_workspace_focus_observed and + self.smoke_workspace_close_observed and + self.smoke_workspace_restart_observed; + } + if (!scripted_actions) { + const value = self.model.graph orelse return false; + if (value.nodes.items.len < 2) return false; + } + const workspace = self.workspace orelse { + if (scripted_actions) std.debug.print("smoke contract missing workspace\n", .{}); + return false; + }; + var client: c.RECT = undefined; + if (c.GetClientRect(self.window.hwnd, &client) == 0) return false; + const layout_width = @max(0, client.right - Tokens.sidebar_width); + const layout_height = Tokens.workspace_height; + const workspace_ready = if (scripted_actions) + workspace.tabCount() > 0 + else + workspace.hasSurface(0) and workspace.hasSurface(1) and + workspace.hasAttach(0) and workspace.hasAttach(1); + const layout_ok = workspace.layoutMatches( + Tokens.sidebar_width, + @max(0, client.bottom - Tokens.workspace_height), + layout_width, + layout_height, + ); + const actions_ok = self.smoke_workspace_actions_ran and + !self.smoke_workspace_action_failed and + self.smoke_workspace_create_observed and + self.smoke_workspace_split_observed and + self.smoke_workspace_select_observed and + self.smoke_workspace_focus_observed and + self.smoke_workspace_close_observed and + self.smoke_workspace_restart_observed; + const passed = if (scripted_actions) actions_ok else layout_ok and workspace_ready; + return passed; +} + +fn envFlag(name: []const u8) bool { + const value = std.process.getEnvVarOwned(std.heap.page_allocator, name) catch return false; + defer std.heap.page_allocator.free(value); + return std.mem.eql(u8, value, "1"); +} + +fn mouseX(lparam: c.LPARAM) i32 { + return CanvasInput.decodeMouseMessage(lparam).x; +} + +fn mouseY(lparam: c.LPARAM) i32 { + return CanvasInput.decodeMouseMessage(lparam).y; +} + +fn clientRight(hwnd: c.HWND) i32 { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + return client.right; +} + +fn clientBottom(hwnd: c.HWND) i32 { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + return client.bottom; +} + +test "edge drop source remains valid across synchronous capture cancellation" { + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = undefined, + .daemon = undefined, + .model = undefined, + }; + app.edge_drag_source_id = try allocator.dupe(u8, "source-node"); + app.canvas.beginEdgeDrag(app.edge_drag_source_id, 10, 10); + + const copied = app.copyEdgeDragSourceForDrop() orelse return error.MissingSource; + defer allocator.free(copied); + app.cancelCanvasInteraction(); + + try std.testing.expectEqualStrings("source-node", copied); + try std.testing.expectEqual(@as(usize, 0), app.edge_drag_source_id.len); + try std.testing.expect(!app.canvas.edge_dragging); +} diff --git a/graphcode-windows/src/CanvasInput.zig b/graphcode-windows/src/CanvasInput.zig new file mode 100644 index 00000000..4e9a464d --- /dev/null +++ b/graphcode-windows/src/CanvasInput.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const WheelMessage = struct { + point: c.POINT, + delta: i16, +}; + +pub const MouseMessage = struct { + x: i32, + y: i32, +}; + +pub fn decodeMouseMessage(lparam: c.LPARAM) MouseMessage { + const value: usize = @bitCast(lparam); + return .{ .x = signedWord(value), .y = signedWord(value >> 16) }; +} + +pub fn decodeWheelMessage(lparam: c.LPARAM, wparam: c.WPARAM) WheelMessage { + return .{ + .point = .{ + .x = signedWord(@as(usize, @bitCast(lparam))), + .y = signedWord(@as(usize, @bitCast(lparam)) >> 16), + }, + .delta = @as(i16, @bitCast(@as(u16, @truncate(@as(usize, @bitCast(wparam)) >> 16)))), + }; +} + +pub fn screenToClient(hwnd: c.HWND, point: c.POINT) ?c.POINT { + return screenToClientWith(hwnd, point, c.ScreenToClient); +} + +fn screenToClientWith(hwnd: c.HWND, point: c.POINT, mapper: anytype) ?c.POINT { + var mapped = point; + if (mapper(hwnd, &mapped) == 0) return null; + return mapped; +} + +fn signedWord(value: usize) i32 { + return @as(i32, @as(i16, @bitCast(@as(u16, @truncate(value))))); +} + +test "wheel message decodes negative and positive signed deltas" { + try std.testing.expectEqual(@as(i16, -120), decodeWheelMessage(0, @as(c.WPARAM, 0xFF880000)).delta); + try std.testing.expectEqual(@as(i16, 120), decodeWheelMessage(0, @as(c.WPARAM, 0x00780000)).delta); +} + +test "screen wheel point preserves non-origin client mapping contract" { + const screen = c.POINT{ .x = 1320, .y = 760 }; + const mapped = screenToClientWith(null, screen, fakeScreenToClient); + try std.testing.expect(mapped != null); + try std.testing.expectEqual(@as(i32, 120), mapped.?.x); + try std.testing.expectEqual(@as(i32, 120), mapped.?.y); +} + +test "mouse message decodes signed client coordinates" { + const decoded = decodeMouseMessage(@as(c.LPARAM, 0xFFF00020)); + try std.testing.expectEqual(@as(i32, 32), decoded.x); + try std.testing.expectEqual(@as(i32, -16), decoded.y); +} + +fn fakeScreenToClient(hwnd: c.HWND, point: *c.POINT) c.BOOL { + _ = hwnd; + point.x -= 1200; + point.y -= 640; + return 1; +} diff --git a/graphcode-windows/src/CanvasLayoutStore.zig b/graphcode-windows/src/CanvasLayoutStore.zig new file mode 100644 index 00000000..64e255f2 --- /dev/null +++ b/graphcode-windows/src/CanvasLayoutStore.zig @@ -0,0 +1,70 @@ +const std = @import("std"); +const GraphCanvas = @import("GraphCanvas.zig"); + +pub const Store = struct { + allocator: std.mem.Allocator, + path: []u8, + + pub fn init(allocator: std.mem.Allocator) !Store { + const base = resolveSupportDirectory(allocator) catch blk: { + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(profile); + break :blk try std.fs.path.join(allocator, &.{ profile, ".graphcode" }); + }; + defer allocator.free(base); + try std.fs.cwd().makePath(base); + return .{ + .allocator = allocator, + .path = try std.fs.path.join(allocator, &.{ base, "windows-canvas-layout.tsv" }), + }; + } + + pub fn deinit(self: *Store) void { + self.allocator.free(self.path); + self.* = undefined; + } + + pub fn load(self: *Store, state: *GraphCanvas.CanvasState) !void { + const data = std.fs.cwd().readFileAlloc(self.allocator, self.path, 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer self.allocator.free(data); + try state.decodeNodeOffsets(data); + } + + pub fn save(self: *Store, state: *const GraphCanvas.CanvasState) !void { + const data = try state.encodeNodeOffsets(self.allocator); + defer self.allocator.free(data); + const temp_path = try std.fmt.allocPrint( + self.allocator, + "{s}.tmp-{d}", + .{ self.path, std.time.nanoTimestamp() }, + ); + defer self.allocator.free(temp_path); + var file = try std.fs.cwd().createFile(temp_path, .{ .truncate = true }); + file.writeAll(data) catch |err| { + file.close(); + std.fs.cwd().deleteFile(temp_path) catch {}; + return err; + }; + file.close(); + std.os.windows.MoveFileEx( + temp_path, + self.path, + std.os.windows.MOVEFILE_REPLACE_EXISTING | std.os.windows.MOVEFILE_WRITE_THROUGH, + ) catch |err| { + std.fs.cwd().deleteFile(temp_path) catch {}; + return err; + }; + } +}; + +fn resolveSupportDirectory(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { + return value; + } else |_| {} + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(profile); + return std.fs.path.join(allocator, &.{ profile, ".graphcode" }); +} diff --git a/graphcode-windows/src/DaemonClient.zig b/graphcode-windows/src/DaemonClient.zig new file mode 100644 index 00000000..3b1ec130 --- /dev/null +++ b/graphcode-windows/src/DaemonClient.zig @@ -0,0 +1,1444 @@ +const std = @import("std"); +const FrameBuffer = @import("FrameBuffer.zig").FrameBuffer; +const Wire = @import("Wire.zig"); +const Forms = @import("Forms.zig"); +const c = @import("Win32.zig").c; + +pub const EventCallback = *const fn ( + context: ?*anyopaque, + frame: [*]const u8, + length: usize, +) callconv(.c) void; + +pub const DaemonClient = struct { + const V1Expectation = enum { recent_projects, graph_changed }; + const reconnect_initial_ms: i64 = 100; + const reconnect_max_ms: i64 = 4_000; + const negotiation_timeout_ms: i64 = 1_500; + const outbound_capacity: usize = 64; + const inbound_capacity: usize = 128; + + allocator: std.mem.Allocator, + mutex: std.Thread.Mutex = .{}, + condition: std.Thread.Condition = .{}, + worker: ?std.Thread = null, + stop_worker: bool = false, + want_connected: bool = false, + reconnect_requested: bool = false, + retry_now: bool = false, + outbound: [outbound_capacity][]u8 = undefined, + outbound_request_ids: [outbound_capacity]? [36]u8 = [_]? [36]u8{null} ** outbound_capacity, + outbound_expectations: [outbound_capacity]V1Expectation = [_]V1Expectation{.graph_changed} ** outbound_capacity, + outbound_head: usize = 0, + outbound_count: usize = 0, + worker_busy: bool = false, + inbound: [inbound_capacity][]u8 = undefined, + inbound_head: usize = 0, + inbound_count: usize = 0, + pipe: c.HANDLE = c.INVALID_HANDLE_VALUE, + pipe_name: []u8 = &.{}, + client_id: [36]u8 = undefined, + mode: Wire.ProtocolMode = .v2, + state: Wire.ConnectionState = .disconnected, + selected_version: u8 = Wire.current_version, + last_error: []const u8 = "", + resume_from: u64 = 0, + next_request: u64 = 1, + next_draft: u64 = 1, + pending_request_ids: [64][36]u8 = undefined, + pending_request_count: usize = 0, + v1_pending_count: usize = 0, + v1_pending_expectation: ?V1Expectation = null, + subscription_path: []const u8 = "", + subgraph_node_id: []const u8 = "", + frame_buffer: FrameBuffer, + retry_at_ms: i64 = 0, + retry_delay_ms: i64 = reconnect_initial_ms, + negotiation_deadline_ms: i64 = 0, + fallback_to_v1: bool = false, + callback: ?EventCallback = null, + callback_context: ?*anyopaque = null, + + pub fn init(allocator: std.mem.Allocator) !DaemonClient { + var client = DaemonClient{ + .allocator = allocator, + .frame_buffer = try FrameBuffer.init(allocator, .v2), + }; + errdefer client.frame_buffer.deinit(); + makeClientID(&client.client_id); + client.pipe_name = endpointName(allocator) catch + try allocator.dupe(u8, "\\\\.\\pipe\\graphcode-daemon-unavailable"); + return client; + } + + pub fn start(self: *DaemonClient) !void { + self.worker = try std.Thread.spawn(.{}, workerMain, .{self}); + } + + pub fn deinit(self: *DaemonClient) void { + self.mutex.lock(); + self.stop_worker = true; + self.want_connected = false; + self.condition.broadcast(); + self.mutex.unlock(); + if (self.worker) |thread| thread.join(); + self.clearQueues(); + if (self.pipe_name.len != 0) self.allocator.free(self.pipe_name); + if (self.subscription_path.len != 0) self.allocator.free(self.subscription_path); + if (self.subgraph_node_id.len != 0) self.allocator.free(self.subgraph_node_id); + self.frame_buffer.deinit(); + } + + pub fn setCallback( + self: *DaemonClient, + callback: EventCallback, + context: ?*anyopaque, + ) void { + self.callback = callback; + self.callback_context = context; + } + + pub fn setSubscription(self: *DaemonClient, project_path: []const u8) void { + const copy = self.allocator.dupe(u8, project_path) catch { + self.mutex.lock(); + self.last_error = "subscription allocation failed"; + self.mutex.unlock(); + return; + }; + self.mutex.lock(); + if (std.mem.eql(u8, self.subscription_path, project_path)) { + self.mutex.unlock(); + self.allocator.free(copy); + return; + } + + if (self.subscription_path.len != 0) self.allocator.free(self.subscription_path); + self.subscription_path = copy; + self.reconnect_requested = true; + self.retry_now = true; + self.condition.signal(); + self.mutex.unlock(); + self.publishState(.reconnecting, ""); + } + + pub fn setSubgraphAddress(self: *DaemonClient, node_id: ?[]const u8) void { + const replacement = if (node_id) |value| + self.allocator.dupe(u8, value) catch return + else + &.{}; + if (self.subgraph_node_id.len != 0) self.allocator.free(self.subgraph_node_id); + self.subgraph_node_id = replacement; + } + + pub fn subscriptionPath(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + return self.subscriptionSnapshotWithAllocator(allocator); + } + + pub fn validateSettings( + self: *DaemonClient, + pipe_override: []const u8, + support_directory: []const u8, + ) !void { + _ = self; + const allocator = std.heap.page_allocator; + if (pipe_override.len != 0 and + (!std.mem.startsWith(u8, pipe_override, "\\\\.\\pipe\\") or pipe_override.len > 240)) + return error.InvalidDaemonPipe; + const support = try supportDirectoryFor(allocator, support_directory); + defer allocator.free(support); + try validateSupportDirectory(allocator, support); + const endpoint = try endpointNameFor(allocator, pipe_override, support); + defer allocator.free(endpoint); + if (!std.mem.startsWith(u8, endpoint, "\\\\.\\pipe\\") or endpoint.len > 240) + return error.InvalidDaemonPipe; + } + + pub fn applySettings( + self: *DaemonClient, + pipe_override: []const u8, + support_directory: []const u8, + ) !void { + try self.validateSettings(pipe_override, support_directory); + const old_pipe = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_DAEMON_PIPE") catch null; + defer if (old_pipe) |value| self.allocator.free(value); + const old_support = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SUPPORT_DIR") catch null; + defer if (old_support) |value| self.allocator.free(value); + setEnvironmentChecked("GRAPHCODE_DAEMON_PIPE", if (pipe_override.len == 0) null else pipe_override) catch return error.EnvironmentUpdateFailed; + setEnvironmentChecked("GRAPHCODE_SUPPORT_DIR", if (support_directory.len == 0) null else support_directory) catch { + setEnvironmentChecked("GRAPHCODE_DAEMON_PIPE", old_pipe) catch {}; + return error.EnvironmentUpdateFailed; + }; + self.reconnect(); + } + + pub fn effectiveSettings(self: *DaemonClient, allocator: std.mem.Allocator) !Forms.Settings { + _ = self; + const pipe = std.process.getEnvVarOwned(allocator, "GRAPHCODE_DAEMON_PIPE") catch try allocator.dupe(u8, ""); + errdefer allocator.free(pipe); + const support = std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR") catch try allocator.dupe(u8, ""); + return .{ .daemon_pipe = pipe, .support_directory = support }; + } + + pub fn currentEndpointName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + _ = self; + return endpointName(allocator); + } + + pub fn currentDaemonLockName(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + _ = self; + const support = try supportDirectory(allocator); + defer allocator.free(support); + const normalized = try normalizedSupportPath(allocator, support); + defer allocator.free(normalized); + const support_hash = try sha256Hex(allocator, normalized); + defer allocator.free(support_hash); + const sid = try currentSID(allocator); + defer allocator.free(sid); + return std.fmt.allocPrint( + allocator, + "Global\\graphcode-daemon-{s}-{s}", + .{ sid, support_hash[0..20] }, + ); + } + + fn validateSupportDirectory(allocator: std.mem.Allocator, support_directory: []const u8) !void { + const normalized = try normalizedSupportPath(allocator, support_directory); + defer std.heap.page_allocator.free(normalized); + const wide = try utf8ToWide(allocator, normalized); + defer allocator.free(wide); + const attributes = c.GetFileAttributesW(wide.ptr); + if (attributes == c.INVALID_FILE_ATTRIBUTES or + (attributes & c.FILE_ATTRIBUTE_DIRECTORY) == 0) + return error.SupportDirectoryMissing; + const secret_path = try std.fs.path.join(allocator, &.{ normalized, ".graphcode-rendezvous.secret" }); + defer allocator.free(secret_path); + const secret = std.fs.cwd().readFileAlloc( + allocator, + secret_path, + 4096, + ) catch return error.SupportSecretMissing; + defer allocator.free(secret); + if (secret.len != 32 or std.mem.allEqual(u8, secret, 0)) + return error.SupportSecretInvalid; + } + + pub fn connect(self: *DaemonClient) void { + self.mutex.lock(); + self.want_connected = true; + self.condition.signal(); + self.mutex.unlock(); + } + + pub fn reconnect(self: *DaemonClient) void { + self.mutex.lock(); + self.want_connected = true; + self.reconnect_requested = true; + self.retry_now = true; + self.condition.signal(); + self.mutex.unlock(); + } + + pub fn close(self: *DaemonClient) void { + self.mutex.lock(); + self.want_connected = false; + self.reconnect_requested = false; + self.clearOutboundLocked(); + self.condition.signal(); + self.mutex.unlock(); + } + + pub fn sendListProjects(self: *DaemonClient) void { + const command = Wire.commandListRecentProjects(self.allocator) catch return; + self.sendCommand(command); + } + + pub fn sendRestoreOpenProjects(self: *DaemonClient) void { + const command = Wire.commandRestoreOpenProjects(self.allocator) catch return; + self.sendCommand(command); + } + + pub fn sendOpenGlobalGraph(self: *DaemonClient) void { + const command = Wire.commandOpenGlobalGraph(self.allocator) catch return; + self.sendCommand(command); + } + + pub fn sendOpenProject(self: *DaemonClient, path: []const u8) ?[36]u8 { + if (self.protocolMode() == .v1 and !self.v1OpenReady()) return null; + const command = Wire.commandOpenProject(self.allocator, path) catch return null; + var request_id: [36]u8 = undefined; + self.mutex.lock(); + makeRequestID(&request_id, self.next_request); + self.next_request +%= 1; + self.mutex.unlock(); + if (!self.sendCommandWithRequestID(command, request_id)) return null; + return request_id; + } + + pub fn sendCloseProject(self: *DaemonClient, path: []const u8) void { + const command = Wire.commandCloseProject(self.allocator, path) catch return; + self.sendCommand(command); + } + + pub fn sendForgetProject(self: *DaemonClient, path: []const u8) void { + const command = Wire.commandForgetProject(self.allocator, path) catch return; + self.sendCommand(command); + } + + pub fn sendDeleteProjectGraph(self: *DaemonClient, path: []const u8) void { + const command = Wire.commandDeleteProjectGraph(self.allocator, path) catch return; + self.sendCommand(command); + } + + pub fn v1OpenReady(self: *DaemonClient) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.mode != .v1 or + (self.outbound_count == 0 and !self.worker_busy and self.v1_pending_expectation == null); + } + + pub fn sendListQuickChats(self: *DaemonClient) void { + const command = Wire.commandListQuickChats(self.allocator) catch return; + self.sendCommand(command); + } + + pub fn sendCreateQuickChat(self: *DaemonClient, title: []const u8, backend: []const u8) void { + const command = Wire.commandCreateQuickChat(self.allocator, title, backend) catch return; + self.sendCommand(command); + } + + pub fn sendOpenQuickChat(self: *DaemonClient, id: []const u8) void { + const command = Wire.commandOpenQuickChat(self.allocator, id) catch return; + self.sendCommand(command); + } + + pub fn sendRenameQuickChat(self: *DaemonClient, id: []const u8, title: []const u8) void { + const command = Wire.commandRenameQuickChat(self.allocator, id, title) catch return; + self.sendCommand(command); + } + + pub fn sendDeleteQuickChat(self: *DaemonClient, id: []const u8) void { + const command = Wire.commandDeleteQuickChat(self.allocator, id) catch return; + self.sendCommand(command); + } + + pub fn sendCreateNode(self: *DaemonClient, project_path: []const u8, title: []const u8) void { + self.sendCreateNodeConfigured(project_path, title, "claudeCode", null); + } + + pub fn sendCreateNodeConfigured( + self: *DaemonClient, + project_path: []const u8, + title: []const u8, + backend: []const u8, + model_tier: ?[]const u8, + ) void { + var node_id: [36]u8 = undefined; + self.mutex.lock(); + const sequence = self.next_draft; + self.next_draft +%= 1; + self.mutex.unlock(); + makeRequestID(&node_id, sequence); + const command = Wire.commandGraphCreateNodeConfigured( + self.allocator, + project_path, + title, + &node_id, + backend, + model_tier, + ) catch return; + self.sendCommand(command); + } + + pub fn sendCreateNodeDraft( + self: *DaemonClient, + project_path: []const u8, + draft: Forms.NodeDraft, + ) void { + var node_id: [36]u8 = undefined; + self.mutex.lock(); + const sequence = self.next_draft; + self.next_draft +%= 1; + self.mutex.unlock(); + makeRequestID(&node_id, sequence); + const command = Wire.commandGraphCreateNodeFull(self.allocator, project_path, &node_id, draft) catch { + self.publishState(self.connectionState(), "create node command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendNodeAction( + self: *DaemonClient, + project_path: []const u8, + node_id: []const u8, + action: []const u8, + text: ?[]const u8, + ) void { + const command = Wire.commandGraphNodeAction( + self.allocator, + project_path, + node_id, + action, + text, + ) catch return; + self.sendCommand(command); + } + + pub fn sendRenameNode(self: *DaemonClient, project_path: []const u8, node_id: []const u8, title: []const u8) void { + const command = Wire.commandGraphRenameNode(self.allocator, project_path, node_id, title) catch { + self.publishState(self.connectionState(), "rename node command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendDeleteNode(self: *DaemonClient, project_path: []const u8, node_id: []const u8) void { + const command = Wire.commandGraphDeleteNode(self.allocator, project_path, node_id) catch { + self.publishState(self.connectionState(), "delete node command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendDeleteEdge(self: *DaemonClient, project_path: []const u8, edge_id: []const u8) void { + const command = Wire.commandGraphDeleteEdge(self.allocator, project_path, edge_id) catch { + self.publishState(self.connectionState(), "delete edge command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendCreateEdge( + self: *DaemonClient, + project_path: []const u8, + from: []const u8, + to: []const u8, + kind: []const u8, + ) void { + const command = Wire.commandGraphCreateEdge(self.allocator, project_path, from, to, kind) catch { + self.publishState(self.connectionState(), "create edge command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendCreateEdgeDraft( + self: *DaemonClient, + project_path: []const u8, + draft: Forms.EdgeDraft, + ) void { + const command = Wire.commandGraphCreateEdgeFull( + self.allocator, project_path, draft.from, draft.to, draft, + ) catch { + self.publishState(self.connectionState(), "create edge command encoding failed"); + return; + }; + self.sendCommand(command); + } + + pub fn sendPilotComposite(self: *DaemonClient, project_path: []const u8, node_id: []const u8) void { + const command = Wire.commandGraphPilotComposite(self.allocator, project_path, node_id) catch return; + self.sendCommand(command); + } + + pub fn sendArmComposite(self: *DaemonClient, project_path: []const u8, node_id: []const u8) void { + const command = Wire.commandGraphArmComposite(self.allocator, project_path, node_id) catch return; + self.sendCommand(command); + } + + pub fn sendRefreshUsage(self: *DaemonClient, project_path: []const u8) void { + const command = Wire.commandGraphRefreshUsage(self.allocator, project_path) catch return; + self.sendCommand(command); + } + + pub fn sendMemoNode( + self: *DaemonClient, + project_path: []const u8, + node_id: []const u8, + text: []const u8, + from: ?[]const u8, + ) void { + const command = Wire.commandGraphMemoNode(self.allocator, project_path, node_id, text, from) catch return; + self.sendCommand(command); + } + + pub fn sendUpdateNode( + self: *DaemonClient, + project_path: []const u8, + node_id: []const u8, + update_json: []const u8, + ) void { + const command = Wire.commandGraphUpdateNode(self.allocator, project_path, node_id, update_json) catch return; + self.sendCommand(command); + } + + pub fn sendUpdateNodeForm( + self: *DaemonClient, + project_path: []const u8, + node_id: []const u8, + update: Forms.NodeUpdate, + ) void { + const command = Wire.commandGraphUpdateNodeForm(self.allocator, project_path, node_id, update) catch return; + self.sendCommand(command); + } + + pub fn poll(self: *DaemonClient) void { + var count: usize = 0; + while (count < inbound_capacity) : (count += 1) { + self.mutex.lock(); + if (self.inbound_count == 0) { + self.mutex.unlock(); + return; + } + const frame = self.inbound[self.inbound_head]; + self.inbound_head = (self.inbound_head + 1) % inbound_capacity; + self.inbound_count -= 1; + self.mutex.unlock(); + defer self.allocator.free(frame); + if (self.callback) |callback| callback(self.callback_context, frame.ptr, frame.len); + } + } + + pub fn connectionState(self: *const DaemonClient) Wire.ConnectionState { + const client: *DaemonClient = @constCast(self); + client.mutex.lock(); + defer client.mutex.unlock(); + return client.state; + } + + pub fn protocolMode(self: *DaemonClient) Wire.ProtocolMode { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.mode; + } + + pub fn isIdle(self: *const DaemonClient) bool { + const client: *DaemonClient = @constCast(self); + client.mutex.lock(); + defer client.mutex.unlock(); + return client.state == .connected and + client.outbound_count == 0 and + client.inbound_count == 0 and + client.pending_request_count == 0 and + !client.worker_busy; + } + + pub fn statusText(self: *const DaemonClient) []const u8 { + const client: *DaemonClient = @constCast(self); + client.mutex.lock(); + defer client.mutex.unlock(); + if (client.last_error.len != 0) return client.last_error; + return switch (client.state) { + .disconnected => "Disconnected", + .connecting => "Connecting…", + .negotiating => "Negotiating daemon protocol…", + .connected => if (self.mode == .v2) "Connected · protocol v2" else "Connected · protocol v1", + .reconnecting => "Reconnecting…", + .unavailable => "Daemon unavailable · retrying", + .protocol_error => "Daemon protocol error", + }; + } + + fn expectationForCommand(command_json: []const u8) V1Expectation { + if (std.mem.indexOf(u8, command_json, "\"listRecentProjects\"") != null or + std.mem.indexOf(u8, command_json, "\"restoreOpenProjects\"") != null) + return .recent_projects; + return .graph_changed; + } + + fn sendCommand(self: *DaemonClient, command_json: []u8) void { + var addressed = command_json; + if (self.subgraph_node_id.len != 0 and + std.mem.indexOf(u8, command_json, "\"graphCommand\"") != null) + { + addressed = Wire.addressGraphCommandToSubGraph( + self.allocator, + command_json, + self.subgraph_node_id, + ) catch { + self.allocator.free(command_json); + self.mutex.lock(); + self.last_error = "unable to address composite graph command"; + self.mutex.unlock(); + return; + }; + self.allocator.free(command_json); + } + _ = self.sendCommandInternal(addressed, null); + } + + fn sendCommandWithRequestID(self: *DaemonClient, command_json: []u8, request_id: [36]u8) bool { + return self.sendCommandInternal(command_json, request_id); + } + + fn sendCommandInternal(self: *DaemonClient, command_json: []u8, request_id: ?[36]u8) bool { + self.mutex.lock(); + if (self.stop_worker or self.outbound_count == outbound_capacity) { + self.last_error = "daemon outbound queue is full"; + self.mutex.unlock(); + self.allocator.free(command_json); + return false; + } + const index = (self.outbound_head + self.outbound_count) % outbound_capacity; + self.outbound[index] = @constCast(command_json); + self.outbound_request_ids[index] = request_id; + self.outbound_expectations[index] = expectationForCommand(command_json); + self.outbound_count += 1; + self.condition.signal(); + self.mutex.unlock(); + return true; + } + + fn openPipe(self: *DaemonClient) bool { + const wide = utf8ToWide(self.allocator, self.pipe_name) catch return false; + defer self.allocator.free(wide); + const handle = c.CreateFileW( + wide.ptr, + c.GENERIC_READ | c.GENERIC_WRITE, + 0, + null, + c.OPEN_EXISTING, + c.FILE_FLAG_OVERLAPPED, + null, + ); + if (handle == c.INVALID_HANDLE_VALUE) { + if (c.GetLastError() == c.ERROR_PIPE_BUSY) { + _ = c.WaitNamedPipeW(wide.ptr, 50); + } + return false; + } + self.pipe = handle; + return true; + } + + fn workerMain(self: *DaemonClient) void { + while (true) { + self.mutex.lock(); + const stop = self.stop_worker; + const want_connected = self.want_connected; + const reconnect_pending = self.reconnect_requested; + const retry_immediately = self.retry_now; + self.reconnect_requested = false; + self.retry_now = false; + self.mutex.unlock(); + if (stop) break; + + if (!want_connected) { + if (self.pipe != c.INVALID_HANDLE_VALUE) self.closeHandleOnly(); + self.clearPendingRequests(); + if (self.state != .disconnected) self.publishState(.disconnected, ""); + if (self.waitForWork(50)) break; + continue; + } + + if (reconnect_pending) { + self.closeHandleOnly(); + self.clearPendingRequests(); + self.retry_at_ms = nowMilliseconds(); + self.retry_delay_ms = reconnect_initial_ms; + self.publishState(.reconnecting, ""); + } + if (retry_immediately) self.retry_at_ms = 0; + + const now = nowMilliseconds(); + if (self.pipe == c.INVALID_HANDLE_VALUE and now >= self.retry_at_ms) { + self.attemptConnection(now); + } + + if (self.pipe != c.INVALID_HANDLE_VALUE) { + if (!self.pumpIncoming()) continue; + if (self.state == .negotiating and nowMilliseconds() >= self.negotiation_deadline_ms) { + self.beginLegacyFallback(nowMilliseconds()); + } + } + + if (self.pipe != c.INVALID_HANDLE_VALUE and self.state == .connected) { + if (self.dequeueOutbound()) |outbound| { + self.sendCommandOnWorker(outbound.command, outbound.request_id, outbound.expectation); + self.allocator.free(outbound.command); + self.mutex.lock(); + self.worker_busy = false; + self.mutex.unlock(); + } + } + if (self.waitForWork(25)) break; + } + if (self.pipe != c.INVALID_HANDLE_VALUE) self.closeHandleOnly(); + self.clearPendingRequests(); + self.clearQueues(); + self.publishState(.disconnected, ""); + } + + fn waitForWork(self: *DaemonClient, milliseconds: u64) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.stop_worker) return true; + if (self.outbound_count == 0 and self.inbound_count == 0) { + _ = self.condition.timedWait(&self.mutex, milliseconds * std.time.ns_per_ms) catch {}; + } + return self.stop_worker; + } + + const OutboundCommand = struct { + command: []u8, + request_id: ?[36]u8, + expectation: V1Expectation, + }; + + fn dequeueOutbound(self: *DaemonClient) ?OutboundCommand { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.outbound_count == 0) return null; + if (self.mode == .v1 and self.v1_pending_expectation != null) return null; + const command = self.outbound[self.outbound_head]; + const request_id = self.outbound_request_ids[self.outbound_head]; + const expectation = self.outbound_expectations[self.outbound_head]; + self.outbound_request_ids[self.outbound_head] = null; + self.outbound_head = (self.outbound_head + 1) % outbound_capacity; + self.outbound_count -= 1; + self.worker_busy = true; + return .{ .command = command, .request_id = request_id, .expectation = expectation }; + } + + fn sendCommandOnWorker( + self: *DaemonClient, + command_json: []const u8, + requested_id: ?[36]u8, + expectation: V1Expectation, + ) void { + const frame = if (self.mode == .v2) blk: { + var request_id: [36]u8 = undefined; + if (requested_id) |value| { + request_id = value; + } else { + makeRequestID(&request_id, self.next_request); + self.next_request +%= 1; + } + if (!self.trackRequest(&request_id)) { + self.publishState(self.state, "too many outstanding daemon requests"); + return; + } + break :blk Wire.v2Request( + self.allocator, + &request_id, + command_json, + ) catch { + self.publishState(self.state, "request encoding failed"); + return; + }; + } else Wire.v1Command(self.allocator, command_json) catch { + self.publishState(self.state, "request encoding failed"); + return; + }; + defer self.allocator.free(frame); + self.writeFrame(frame) catch { + if (self.mode == .v2) { + if (Wire.responseRequestID(frame)) |request_id| { + _ = self.completeRequest(request_id); + } + } + self.markTransportFailure("daemon write failed"); + return; + }; + if (self.mode == .v1) { + self.mutex.lock(); + self.v1_pending_count += 1; + self.v1_pending_expectation = expectation; + self.mutex.unlock(); + } + } + + fn writeFrame(self: *DaemonClient, data: []const u8) !void { + const header = try Wire.frameLength(data, self.mode); + try writeAll(self.pipe, &header); + try writeAll(self.pipe, data); + } + + fn attemptConnection(self: *DaemonClient, now: i64) void { + self.closeHandleOnly(); + self.prepareV2Negotiation(); + if (endpointName(self.allocator)) |current| { + if (!std.mem.eql(u8, current, self.pipe_name)) { + self.allocator.free(self.pipe_name); + self.pipe_name = current; + } else { + self.allocator.free(current); + } + } else |_| {} + self.publishState(.connecting, ""); + if (!self.openPipe()) { + self.scheduleRetry(now, "daemon unavailable; retrying"); + return; + } + self.frame_buffer.reset(); + if (self.fallback_to_v1) { + self.frame_buffer.setMode(.v1); + self.mode = .v1; + self.selected_version = 1; + self.publishState(.connected, ""); + self.fallback_to_v1 = false; + self.retry_delay_ms = reconnect_initial_ms; + return; + } + self.publishState(.negotiating, ""); + self.negotiation_deadline_ms = now + negotiation_timeout_ms; + const subscription = self.subscriptionSnapshot() catch { + self.scheduleRetry(now, "daemon subscription allocation failed"); + return; + }; + defer self.allocator.free(subscription); + const hello = Wire.v2Hello( + self.allocator, + &self.client_id, + if (self.resume_from == 0) null else self.resume_from, + subscription, + ) catch { + self.scheduleRetry(now, "daemon hello encoding failed"); + return; + }; + defer self.allocator.free(hello); + self.writeFrame(hello) catch { + self.scheduleRetry(now, "daemon hello write failed"); + return; + }; + } + + fn prepareV2Negotiation(self: *DaemonClient) void { + self.mode = .v2; + self.selected_version = Wire.current_version; + self.frame_buffer.setMode(.v2); + } + + fn pumpIncoming(self: *DaemonClient) bool { + if (self.pipe == c.INVALID_HANDLE_VALUE) return true; + var round: usize = 0; + while (round < 8) : (round += 1) { + var chunk: [16 * 1024]u8 = undefined; + var overlapped = std.mem.zeroes(c.OVERLAPPED); + overlapped.hEvent = c.CreateEventW(null, 1, 0, null); + if (overlapped.hEvent == null) { + self.markTransportFailure("daemon read event failed"); + return false; + } + defer _ = c.CloseHandle(overlapped.hEvent); + var read: c.DWORD = 0; + const completed = c.ReadFile( + self.pipe, + &chunk, + @intCast(chunk.len), + &read, + &overlapped, + ) != 0; + if (!completed) { + const read_error = c.GetLastError(); + if (read_error == c.ERROR_IO_PENDING) { + const wait_result = c.WaitForSingleObject(overlapped.hEvent, 20); + if (wait_result == c.WAIT_TIMEOUT) { + _ = c.CancelIoEx(self.pipe, &overlapped); + _ = c.GetOverlappedResult(self.pipe, &overlapped, &read, 1); + break; + } + if (wait_result != c.WAIT_OBJECT_0 or + c.GetOverlappedResult(self.pipe, &overlapped, &read, 0) == 0) + { + const result_error = c.GetLastError(); + if (result_error == c.ERROR_OPERATION_ABORTED) break; + self.markTransportFailure("daemon frame read failed"); + return false; + } + } else if (read_error == c.ERROR_NO_DATA or read_error == c.ERROR_BROKEN_PIPE) { + self.markTransportFailure("daemon connection closed"); + return false; + } else { + self.markTransportFailure("daemon frame read failed"); + return false; + } + } + if (read == 0) { + self.markTransportFailure("daemon connection closed"); + return false; + } + self.frame_buffer.append(chunk[0..read]) catch { + self.markProtocolFailure("daemon receive buffer overflow"); + return false; + }; + } + + while (true) { + const frame = self.frame_buffer.next(self.allocator) catch { + self.markProtocolFailure("daemon frame allocation failed"); + return false; + }; + const complete = frame orelse return true; + defer self.allocator.free(complete); + if (!self.handleFrame(complete)) return false; + if (self.state != .connected) return true; + } + } + + fn markTransportFailure(self: *DaemonClient, message: []const u8) void { + self.clearPendingRequests(); + self.closeHandleOnly(); + self.publishState(.reconnecting, message); + self.retry_at_ms = nowMilliseconds() + self.retry_delay_ms; + self.retry_delay_ms = @min(self.retry_delay_ms * 2, reconnect_max_ms); + } + + fn markProtocolFailure(self: *DaemonClient, message: []const u8) void { + self.clearPendingRequests(); + self.closeHandleOnly(); + self.publishState(.protocol_error, message); + self.retry_at_ms = nowMilliseconds() + self.retry_delay_ms; + self.retry_delay_ms = @min(self.retry_delay_ms * 2, reconnect_max_ms); + } + + fn scheduleRetry(self: *DaemonClient, now: i64, message: []const u8) void { + self.closeHandleOnly(); + self.frame_buffer.reset(); + self.publishState(.unavailable, message); + self.retry_at_ms = now + self.retry_delay_ms; + self.retry_delay_ms = @min(self.retry_delay_ms * 2, reconnect_max_ms); + } + + fn beginLegacyFallback(self: *DaemonClient, now: i64) void { + self.closeHandleOnly(); + self.frame_buffer.setMode(.v1); + self.clearPendingRequests(); + self.mode = .v1; + self.selected_version = 1; + self.fallback_to_v1 = true; + self.publishState(.reconnecting, ""); + self.retry_at_ms = now; + } + + fn handleFrame(self: *DaemonClient, frame: []const u8) bool { + if (self.state == .negotiating) { + if (Wire.looksLikeV2(frame) and + std.mem.indexOf(u8, frame, "\"kind\":\"hello\"") != null and + std.mem.indexOf(u8, frame, "\"selectedVersion\":2") != null) + { + self.mode = .v2; + self.frame_buffer.setModePreservingData(.v2); + self.selected_version = 2; + self.publishState(.connected, ""); + self.retry_delay_ms = reconnect_initial_ms; + return true; + } + self.beginLegacyFallback(nowMilliseconds()); + return true; + } + if (self.mode == .v2) { + if (Wire.responseRequestID(frame)) |request_id| { + if (!self.completeRequest(request_id)) { + self.markProtocolFailure("unmatched daemon response"); + return false; + } + } + } + if (self.mode == .v1) { + self.mutex.lock(); + const expected = self.v1_pending_expectation; + const kind = Wire.eventKind(frame); + if (expected != null and + (kind == .error_occurred or + (expected.? == .recent_projects and kind == .recent_projects) or + (expected.? == .graph_changed and kind == .graph_changed))) + { + self.v1_pending_expectation = null; + self.v1_pending_count = 0; + } + self.mutex.unlock(); + } + self.enqueueInbound(frame); + if (Wire.jsonNumber(frame, "sequence")) |sequence| self.resume_from = sequence; + return true; + } + + fn enqueueInbound(self: *DaemonClient, frame: []const u8) void { + const copy = self.allocator.dupe(u8, frame) catch { + self.publishState(self.state, "daemon event allocation failed"); + return; + }; + self.mutex.lock(); + if (self.inbound_count == inbound_capacity) { + self.mutex.unlock(); + self.allocator.free(copy); + self.publishState(self.state, "daemon event queue is full"); + return; + } + const index = (self.inbound_head + self.inbound_count) % inbound_capacity; + self.inbound[index] = copy; + self.inbound_count += 1; + self.condition.signal(); + self.mutex.unlock(); + } + + fn subscriptionSnapshot(self: *DaemonClient) ![]u8 { + return self.subscriptionSnapshotWithAllocator(self.allocator); + } + + fn subscriptionSnapshotWithAllocator(self: *DaemonClient, allocator: std.mem.Allocator) ![]u8 { + self.mutex.lock(); + defer self.mutex.unlock(); + return allocator.dupe(u8, self.subscription_path); + } + +test "subscription can be restored after a rejected project open" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + client.setSubscription("C:\\old-project"); + const previous = try client.subscriptionSnapshot(); + defer std.testing.allocator.free(previous); + client.setSubscription("C:\\rejected-project"); + client.setSubscription(previous); + const restored = try client.subscriptionSnapshot(); + defer std.testing.allocator.free(restored); + try std.testing.expectEqualStrings("C:\\old-project", restored); +} + +test "open project returns the request ID used by the v2 envelope" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + const request_id = client.sendOpenProject("C:\\work\\C").?; + try std.testing.expectEqual(@as(usize, 1), client.outbound_count); + try std.testing.expect(std.mem.eql( + u8, + &client.outbound_request_ids[client.outbound_head].?, + &request_id, + )); + const frame = try Wire.v2Request( + std.testing.allocator, + &request_id, + client.outbound[client.outbound_head], + ); + defer std.testing.allocator.free(frame); + try std.testing.expectEqualStrings(&request_id, Wire.responseRequestID(frame).?); +} + +test "v1 open waits for all earlier commands and retains retry on full queue" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + client.mode = .v1; + client.v1_pending_count = 1; + client.v1_pending_expectation = .graph_changed; + try std.testing.expect(!client.v1OpenReady()); + try std.testing.expect(client.sendOpenProject("C:\\work\\B") == null); + client.v1_pending_count = 0; + for (&client.outbound) |*slot| slot.* = try std.testing.allocator.dupe(u8, ""); + client.outbound_count = outbound_capacity; + try std.testing.expect(client.sendOpenProject("C:\\work\\C") == null); +} + +test "v1 response accounting ignores unsolicited events before the expected result" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + client.mode = .v1; + client.state = .connected; + client.v1_pending_count = 1; + client.v1_pending_expectation = .graph_changed; + try std.testing.expect(client.handleFrame( + "{\"version\":1,\"kind\":\"event\",\"event\":{\"recentProjectsListed\":[]}}", + )); + try std.testing.expectEqual(@as(usize, 1), client.v1_pending_count); + try std.testing.expect(client.handleFrame( + "{\"version\":1,\"kind\":\"event\",\"event\":{\"attentionChanged\":{}}}", + )); + try std.testing.expectEqual(@as(usize, 1), client.v1_pending_count); + try std.testing.expect(client.handleFrame( + "{\"version\":1,\"kind\":\"event\",\"event\":{\"errorOccurred\":\"rejected\"}}", + )); + try std.testing.expectEqual(@as(usize, 0), client.v1_pending_count); +} + + fn publishState(self: *DaemonClient, state: Wire.ConnectionState, message: []const u8) void { + self.mutex.lock(); + self.state = state; + self.last_error = message; + self.mutex.unlock(); + } + + fn trackRequest(self: *DaemonClient, request_id: *const [36]u8) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.pending_request_count == self.pending_request_ids.len) return false; + self.pending_request_ids[self.pending_request_count] = request_id.*; + self.pending_request_count += 1; + return true; + } + + fn completeRequest(self: *DaemonClient, request_id: []const u8) bool { + self.mutex.lock(); + defer self.mutex.unlock(); + if (request_id.len != 36) return false; + for (self.pending_request_ids[0..self.pending_request_count], 0..) |pending, index| { + if (std.ascii.eqlIgnoreCase(&pending, request_id)) { + self.pending_request_count -= 1; + if (index != self.pending_request_count) { + self.pending_request_ids[index] = self.pending_request_ids[self.pending_request_count]; + } + return true; + } + } + return false; + } + + fn clearPendingRequests(self: *DaemonClient) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.pending_request_count = 0; + self.v1_pending_count = 0; + self.v1_pending_expectation = null; + } + + fn clearOutboundLocked(self: *DaemonClient) void { + while (self.outbound_count != 0) { + const index = self.outbound_head; + const command = self.outbound[index]; + self.outbound_head = (self.outbound_head + 1) % outbound_capacity; + self.outbound_count -= 1; + self.outbound_request_ids[index] = null; + self.outbound_expectations[index] = .graph_changed; + self.allocator.free(command); + } + } + + fn clearQueues(self: *DaemonClient) void { + self.mutex.lock(); + self.clearOutboundLocked(); + while (self.inbound_count != 0) { + const frame = self.inbound[self.inbound_head]; + self.inbound_head = (self.inbound_head + 1) % inbound_capacity; + self.inbound_count -= 1; + self.allocator.free(frame); + } + self.mutex.unlock(); + } + + fn closeHandleOnly(self: *DaemonClient) void { + if (self.pipe != c.INVALID_HANDLE_VALUE) { + _ = c.CloseHandle(self.pipe); + self.pipe = c.INVALID_HANDLE_VALUE; + } + self.frame_buffer.reset(); + } +}; + +fn nowMilliseconds() i64 { + return @intCast(std.time.milliTimestamp()); +} + +fn writeAll(handle: c.HANDLE, bytes: []const u8) !void { + var offset: usize = 0; + while (offset < bytes.len) { + var overlapped = std.mem.zeroes(c.OVERLAPPED); + overlapped.hEvent = c.CreateEventW(null, 1, 0, null); + if (overlapped.hEvent == null) return error.WriteFailed; + defer _ = c.CloseHandle(overlapped.hEvent); + var written: c.DWORD = 0; + const amount: c.DWORD = @intCast(@min(bytes.len - offset, std.math.maxInt(c.DWORD))); + if (c.WriteFile(handle, bytes[offset..].ptr, amount, &written, &overlapped) == 0) { + if (c.GetLastError() != c.ERROR_IO_PENDING) return error.WriteFailed; + const wait_result = c.WaitForSingleObject(overlapped.hEvent, 50); + if (wait_result == c.WAIT_TIMEOUT) { + _ = c.CancelIoEx(handle, &overlapped); + _ = c.GetOverlappedResult(handle, &overlapped, &written, 1); + return error.WriteTimeout; + } + if (wait_result != c.WAIT_OBJECT_0) { + return error.WriteFailed; + } + if (c.GetOverlappedResult(handle, &overlapped, &written, 0) == 0) { + return error.WriteFailed; + } + } + if (written == 0) return error.WriteFailed; + offset += written; + } +} + +test "daemon client startup state fits the default stack" { + try std.testing.expect(@sizeOf(DaemonClient) < 1024 * 1024); +} + +test "new negotiations reset legacy fallback state to v2 framing" { + const allocator = std.testing.allocator; + var client = try DaemonClient.init(allocator); + defer client.deinit(); + client.mode = .v1; + client.selected_version = 1; + client.frame_buffer.setMode(.v1); + client.fallback_to_v1 = true; + client.prepareV2Negotiation(); + try std.testing.expectEqual(Wire.ProtocolMode.v2, client.mode); + try std.testing.expectEqual(Wire.current_version, client.selected_version); + const header = try Wire.frameLength(&[_]u8{0} ** (Wire.v2_max_payload + 1), .v1); + try std.testing.expectError(error.PayloadTooLarge, Wire.decodedLength(header, .v2)); +} + +test "successful hello switches a coalesced reconnect buffer back to v2" { + const allocator = std.testing.allocator; + var client = try DaemonClient.init(allocator); + defer client.deinit(); + client.state = .negotiating; + client.mode = .v1; + client.frame_buffer.setMode(.v1); + const hello = "{\"version\":2,\"kind\":\"hello\",\"selectedVersion\":2}"; + const hello_header = try Wire.frameLength(hello, .v1); + const payload = "after-hello"; + const payload_header = try Wire.frameLength(payload, .v2); + try client.frame_buffer.append(&hello_header); + try client.frame_buffer.append(hello); + try client.frame_buffer.append(&payload_header); + try client.frame_buffer.append(payload); + const hello_frame = (try client.frame_buffer.next(allocator)).?; + defer allocator.free(hello_frame); + try std.testing.expect(client.handleFrame(hello_frame)); + const payload_frame = (try client.frame_buffer.next(allocator)).?; + defer allocator.free(payload_frame); + try std.testing.expectEqualStrings(payload, payload_frame); + try std.testing.expectEqual(Wire.ProtocolMode.v2, client.mode); +} + +test "settings validation rejects missing directories, invalid secrets, and pipe syntax" { + var client = try DaemonClient.init(std.testing.allocator); + defer client.deinit(); + try std.testing.expectError( + error.InvalidDaemonPipe, + client.validateSettings("not-a-pipe", ""), + ); + try std.testing.expectError( + error.SupportDirectoryMissing, + client.validateSettings("", "C:\\graphcode\\missing-support-directory"), + ); + try std.testing.expectError( + error.SupportSecretMissing, + client.validateSettings("", "."), + ); +} + +test "clearing support override ignores the old environment override" { + const allocator = std.testing.allocator; + const old_override = std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR") catch null; + const old_profile = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch null; + defer { + setTestEnvironment("GRAPHCODE_SUPPORT_DIR", old_override); + setTestEnvironment("USERPROFILE", old_profile); + if (old_override) |value| allocator.free(value); + if (old_profile) |value| allocator.free(value); + } + setTestEnvironment("GRAPHCODE_SUPPORT_DIR", "C:\\Windows"); + setTestEnvironment("USERPROFILE", "C:\\graphcode-missing-default"); + + var client = try DaemonClient.init(allocator); + defer client.deinit(); + try std.testing.expectError( + error.SupportDirectoryMissing, + client.validateSettings("", ""), + ); +} + +fn setTestEnvironment(name: []const u8, value: ?[]const u8) void { + const name_wide = utf8ToWide(std.heap.page_allocator, name) catch return; + defer std.heap.page_allocator.free(name_wide); + const value_wide = if (value) |text| + (utf8ToWide(std.heap.page_allocator, text) catch return) + else + null; + defer if (value_wide) |wide| std.heap.page_allocator.free(wide); + _ = c.SetEnvironmentVariableW(name_wide.ptr, if (value_wide) |wide| wide.ptr else null); +} + +fn setEnvironmentChecked(name: []const u8, value: ?[]const u8) !void { + if (std.process.getEnvVarOwned(std.heap.page_allocator, "GRAPHCODE_TEST_FAIL_SET_ENV")) |fail| { + defer std.heap.page_allocator.free(fail); + if (std.mem.eql(u8, fail, name)) return error.EnvironmentUpdateFailed; + } else |_| {} + const name_wide = try utf8ToWide(std.heap.page_allocator, name); + defer std.heap.page_allocator.free(name_wide); + const value_wide = if (value) |text| try utf8ToWide(std.heap.page_allocator, text) else null; + defer if (value_wide) |wide| std.heap.page_allocator.free(wide); + if (c.SetEnvironmentVariableW(name_wide.ptr, if (value_wide) |wide| wide.ptr else null) == 0) + return error.EnvironmentUpdateFailed; +} + +fn endpointName(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_DAEMON_PIPE")) |override| { + return override; + } else |_| {} + + const support = try supportDirectory(allocator); + defer allocator.free(support); + return endpointNameFor(allocator, "", support); +} + +fn endpointNameFor( + allocator: std.mem.Allocator, + pipe_override: []const u8, + support: []const u8, +) ![]u8 { + if (pipe_override.len != 0) return allocator.dupe(u8, pipe_override); + const sid = try currentSID(allocator); + defer allocator.free(sid); + const support_identity = normalizedSupportPath(allocator, support) catch + return error.EndpointHashFailed; + defer allocator.free(support_identity); + const support_hash = sha256Hex(allocator, support_identity) catch + return error.EndpointHashFailed; + defer allocator.free(support_hash); + const secret_path = try std.fs.path.join(allocator, &.{ support, ".graphcode-rendezvous.secret" }); + defer allocator.free(secret_path); + const secret = std.fs.cwd().readFileAlloc(allocator, secret_path, 4096) catch return error.EndpointSecretMissing; + defer allocator.free(secret); + const rendezvous_hash = sha256Hex(allocator, secret) catch return error.EndpointHashFailed; + defer allocator.free(rendezvous_hash); + return std.fmt.allocPrint( + allocator, + "\\\\.\\pipe\\graphcode-{s}-{s}-{s}", + .{ sid, support_hash[0..24], rendezvous_hash[0..24] }, + ); +} + +fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { + defer allocator.free(value); + return resolveSupportPath(allocator, value); + } else |_| {} + return defaultSupportDirectory(allocator); +} + +fn supportDirectoryFor(allocator: std.mem.Allocator, submitted: []const u8) ![]u8 { + if (submitted.len == 0) return defaultSupportDirectory(allocator); + return resolveSupportPath(allocator, submitted); +} + +fn defaultSupportDirectory(allocator: std.mem.Allocator) ![]u8 { + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch + return error.UserProfileMissing; + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, ".graphcode" }); +} + +fn resolveSupportPath(allocator: std.mem.Allocator, configured: []const u8) ![]u8 { + const value = try allocator.dupe(u8, configured); + if (isAbsoluteWindowsPath(value)) return value; + const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch + return value; + defer allocator.free(home); + const joined = std.fs.path.join(allocator, &.{ home, value }) catch { + return value; + }; + allocator.free(value); + return joined; +} + +fn isAbsoluteWindowsPath(path: []const u8) bool { + return (path.len >= 2 and path[1] == ':') or + (path.len >= 2 and path[0] == '\\' and path[1] == '\\') or + (path.len >= 2 and path[0] == '/' and path[1] == '/'); +} + +fn normalizedSupportPath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const wide = try utf8ToWide(allocator, path); + defer allocator.free(wide); + var buffer: [32768]u16 = undefined; + const length = c.GetFullPathNameW(wide.ptr, buffer.len, &buffer, null); + if (length == 0 or length >= buffer.len) return error.SupportPathNormalizationFailed; + const utf8 = try std.unicode.utf16LeToUtf8Alloc(allocator, buffer[0..length]); + defer allocator.free(utf8); + const result = try allocator.dupe(u8, utf8); + for (result) |*byte| { + if (byte.* >= 'A' and byte.* <= 'Z') byte.* += 'a' - 'A'; + if (byte.* == '\\') byte.* = '/'; + } + return result; +} + +fn currentSID(allocator: std.mem.Allocator) ![]u8 { + var token: c.HANDLE = null; + if (c.OpenProcessToken(c.GetCurrentProcess(), c.TOKEN_QUERY, &token) == 0) { + return error.OpenProcessTokenFailed; + } + defer _ = c.CloseHandle(token); + var required: c.DWORD = 0; + _ = c.GetTokenInformation(token, c.TokenUser, null, 0, &required); + if (required == 0) return error.TokenInformationFailed; + const memory = try allocator.alloc(u8, required); + defer allocator.free(memory); + if (c.GetTokenInformation(token, c.TokenUser, memory.ptr, required, &required) == 0) { + return error.TokenInformationFailed; + } + const token_user: *c.TOKEN_USER = @ptrCast(@alignCast(memory.ptr)); + var sid_text: c.LPWSTR = null; + if (c.ConvertSidToStringSidW(token_user.User.Sid, &sid_text) == 0) { + return error.SidConversionFailed; + } + defer _ = c.LocalFree(sid_text); + return wideToUtf8(allocator, sid_text); +} + +fn sha256Hex(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + const result = try allocator.alloc(u8, 64); + const alphabet = "0123456789abcdef"; + for (digest, 0..) |byte, index| { + result[index * 2] = alphabet[byte >> 4]; + result[index * 2 + 1] = alphabet[byte & 0x0f]; + } + return result; +} + +test "support identity matches Swift standardized file URL hashing" { + const allocator = std.testing.allocator; + const normalized = try normalizedSupportPath(allocator, "C:\\Users\\Test User\\.graphcode"); + defer allocator.free(normalized); + try std.testing.expectEqualStrings("c:/users/test user/.graphcode", normalized); + + const digest = try sha256Hex(allocator, normalized); + defer allocator.free(digest); + try std.testing.expectEqualStrings( + "854409ec88ff68bd47d6f7d1c63e12e8d52fa8c695f28719df2c47e1fd1f0221", + digest, + ); +} + +test "response correlation accepts Swift uppercase UUID serialization" { + const allocator = std.testing.allocator; + var client = try DaemonClient.init(allocator); + defer client.deinit(); + const lower = "00000000-0000-4000-8000-abcdef123456"; + var request_id: [36]u8 = undefined; + @memcpy(&request_id, lower); + try std.testing.expect(client.trackRequest(&request_id)); + try std.testing.expect(client.completeRequest("00000000-0000-4000-8000-ABCDEF123456")); + try std.testing.expectEqual(@as(usize, 0), client.pending_request_count); +} + +fn utf8ToWide(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +fn wideToUtf8(allocator: std.mem.Allocator, value: [*:0]const u16) ![]u8 { + const slice = std.mem.sliceTo(value, 0); + return std.unicode.utf16LeToUtf8Alloc(allocator, slice); +} + +fn makeClientID(buffer: *[36]u8) void { + makeRequestID(buffer, 0); +} + +fn makeRequestID(buffer: *[36]u8, value: u64) void { + const timestamp: u64 = @intCast(std.time.nanoTimestamp()); + const seed = timestamp ^ value; + const result = std.fmt.bufPrint( + buffer, + "00000000-0000-4000-8000-{x:0>12}", + .{seed & 0xffffffffffff}, + ) catch unreachable; + _ = result; +} diff --git a/graphcode-windows/src/DaemonSupervisor.zig b/graphcode-windows/src/DaemonSupervisor.zig new file mode 100644 index 00000000..54760ef7 --- /dev/null +++ b/graphcode-windows/src/DaemonSupervisor.zig @@ -0,0 +1,474 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const Probe = enum { available, busy, missing, unknown }; +const startup_reservation_timeout_ms: i64 = 5_000; +const ReservationWait = enum { acquired, missing, timed_out }; + +pub const Supervisor = struct { + allocator: std.mem.Allocator, + process: c.HANDLE = null, + shutdown_event: c.HANDLE = null, + startup_event: c.HANDLE = null, + startup_handoff_event: c.HANDLE = null, + startup_reservation: c.HANDLE = null, + owned: bool = false, + failure: []u8 = &.{}, + + pub fn start(self: *Supervisor, endpoint: []const u8, lock_name: []const u8) void { + const acquired = self.acquireStartupReservationBounded(lock_name) catch { + self.setFailure("Unable to reserve daemon startup"); + return; + }; + if (!acquired) { + self.setFailure("Timed out waiting for daemon startup reservation"); + return; + } + defer self.releaseStartupReservation(); + + const deadline = std.time.milliTimestamp() + 5_000; + const grace_deadline = std.time.milliTimestamp() + 1_000; + while (std.time.milliTimestamp() < deadline) { + switch (probeEndpoint(endpoint)) { + .available, .busy => return, + .unknown => { + self.setFailure("Unable to determine daemon endpoint state"); + return; + }, + .missing => {}, + } + if (daemonLockExists(lock_name) or std.time.milliTimestamp() < grace_deadline) { + std.Thread.sleep(100 * std.time.ns_per_ms); + continue; + } + break; + } + if (probeEndpoint(endpoint) != .missing or daemonLockExists(lock_name)) return; + const exe = siblingDaemon(self.allocator) catch { + self.setFailure("Unable to locate packaged graphcoded.exe"); + return; + }; + defer self.allocator.free(exe); + self.createStartupEvent(lock_name) catch { + self.setFailure("Unable to create daemon startup reservation event"); + return; + }; + self.createStartupHandoffEvent(lock_name) catch { + self.setFailure("Unable to create daemon startup handoff event"); + self.closeStartupEvent(); + return; + }; + self.createShutdownEvent(lock_name) catch { + self.setFailure("Unable to create daemon shutdown event"); + self.closeStartupHandoffEvent(); + self.closeStartupEvent(); + return; + }; + self.spawn(exe) catch { + self.setFailure("Unable to start graphcoded.exe"); + self.closeShutdownEvent(); + self.closeStartupHandoffEvent(); + self.closeStartupEvent(); + return; + }; + if (c.SetEvent(self.startup_event) == 0) { + self.setFailure("Unable to release graphcoded.exe startup gate"); + self.cleanupFailedStartup(); + return; + } + self.clearDaemonChildEnvironment(); + if (!self.waitForChildHandoff()) { + self.setFailure("graphcoded.exe did not complete startup handoff"); + self.cleanupFailedStartup(); + return; + } + self.closeStartupHandoffEvent(); + self.closeStartupEvent(); + } + + pub fn stop(self: *Supervisor) void { + if (!self.owned or self.process == null) return; + if (self.shutdown_event != null) _ = c.SetEvent(self.shutdown_event); + if (c.WaitForSingleObject(self.process, 5_000) == c.WAIT_TIMEOUT) { + self.forceStop(); + } else { + self.closeProcess(); + } + self.closeShutdownEvent(); + self.closeStartupEvent(); + self.closeStartupHandoffEvent(); + } + + pub fn forceStop(self: *Supervisor) void { + if (!self.owned or self.process == null) return; + _ = c.TerminateProcess(self.process, 1); + _ = c.WaitForSingleObject(self.process, 2_000); + self.closeProcess(); + self.closeShutdownEvent(); + self.closeStartupHandoffEvent(); + self.closeStartupEvent(); + } + + pub fn status(self: *const Supervisor) []const u8 { + return self.failure; + } + + fn spawn(self: *Supervisor, exe: []const u8) !void { + const wide_exe = try utf16(self.allocator, exe); + defer self.allocator.free(wide_exe); + const command = try std.fmt.allocPrint(self.allocator, "\"{s}\"", .{exe}); + defer self.allocator.free(command); + const wide_command = try utf16(self.allocator, command); + defer self.allocator.free(wide_command); + var startup: c.STARTUPINFOW = std.mem.zeroes(c.STARTUPINFOW); + startup.cb = @sizeOf(c.STARTUPINFOW); + var info: c.PROCESS_INFORMATION = undefined; + if (c.CreateProcessW( + wide_exe.ptr, + wide_command.ptr, + null, + null, + 0, + c.CREATE_NO_WINDOW, + null, + null, + &startup, + &info, + ) == 0) return error.CreateProcessFailed; + _ = c.CloseHandle(info.hThread); + self.process = info.hProcess; + self.owned = true; + } + + fn createShutdownEvent(self: *Supervisor, lock_name: []const u8) !void { + const name = try std.fmt.allocPrint(self.allocator, "{s}-shutdown", .{lock_name}); + defer self.allocator.free(name); + const wide = try utf16(self.allocator, name); + defer self.allocator.free(wide); + self.shutdown_event = c.CreateEventW(null, 1, 0, wide.ptr); + if (self.shutdown_event == null) return error.EventCreationFailed; + const env_name = try utf16(self.allocator, "GRAPHCODE_DAEMON_SHUTDOWN_EVENT"); + defer self.allocator.free(env_name); + if (c.SetEnvironmentVariableW(env_name.ptr, wide.ptr) == 0) return error.EnvironmentUpdateFailed; + } + + fn acquireStartupReservation(self: *Supervisor, lock_name: []const u8) !void { + const name = try std.fmt.allocPrint(self.allocator, "{s}-startup", .{lock_name}); + defer self.allocator.free(name); + const wide = try utf16(self.allocator, name); + defer self.allocator.free(wide); + self.startup_reservation = c.CreateMutexW(null, 1, wide.ptr); + if (self.startup_reservation == null) return error.ReservationCreationFailed; + if (c.GetLastError() == c.ERROR_ALREADY_EXISTS) { + _ = c.CloseHandle(self.startup_reservation); + self.startup_reservation = null; + return error.ReservationBusy; + } + } + + fn acquireStartupReservationBounded(self: *Supervisor, lock_name: []const u8) !bool { + const deadline = std.time.milliTimestamp() + startup_reservation_timeout_ms; + while (std.time.milliTimestamp() < deadline) { + self.acquireStartupReservation(lock_name) catch |err| switch (err) { + error.ReservationBusy => { + const remaining = deadline - std.time.milliTimestamp(); + if (remaining <= 0) return false; + switch (try self.waitForStartupReservation(lock_name, @intCast(remaining))) { + .acquired => return true, + .missing => continue, + .timed_out => return false, + } + }, + else => return err, + }; + return true; + } + return false; + } + + fn waitForStartupReservation( + self: *Supervisor, + lock_name: []const u8, + timeout_ms: c.DWORD, + ) !ReservationWait { + const name = try std.fmt.allocPrint(self.allocator, "{s}-startup", .{lock_name}); + defer self.allocator.free(name); + const wide = try utf16(self.allocator, name); + defer self.allocator.free(wide); + const handle = c.OpenMutexW(c.SYNCHRONIZE | c.MUTEX_MODIFY_STATE, 0, wide.ptr); + if (handle == null) { + if (c.GetLastError() == c.ERROR_FILE_NOT_FOUND) return .missing; + return error.ReservationOpenFailed; + } + switch (c.WaitForSingleObject(handle, timeout_ms)) { + c.WAIT_OBJECT_0, c.WAIT_ABANDONED => { + self.startup_reservation = handle; + return .acquired; + }, + c.WAIT_TIMEOUT => { + _ = c.CloseHandle(handle); + return .timed_out; + }, + else => { + _ = c.CloseHandle(handle); + return error.ReservationWaitFailed; + }, + } + } + + fn releaseStartupReservation(self: *Supervisor) void { + if (self.startup_reservation != null) { + _ = c.ReleaseMutex(self.startup_reservation); + _ = c.CloseHandle(self.startup_reservation); + } + self.startup_reservation = null; + } + + fn createStartupEvent(self: *Supervisor, lock_name: []const u8) !void { + const name = try std.fmt.allocPrint(self.allocator, "{s}-startup-ready", .{lock_name}); + defer self.allocator.free(name); + const wide = try utf16(self.allocator, name); + defer self.allocator.free(wide); + self.startup_event = c.CreateEventW(null, 1, 0, wide.ptr); + if (self.startup_event == null) return error.EventCreationFailed; + if (c.GetLastError() == c.ERROR_ALREADY_EXISTS) { + _ = c.CloseHandle(self.startup_event); + self.startup_event = null; + return error.EventCreationFailed; + } + _ = c.ResetEvent(self.startup_event); + const env_name = try utf16(self.allocator, "GRAPHCODE_DAEMON_STARTUP_EVENT"); + defer self.allocator.free(env_name); + if (c.SetEnvironmentVariableW(env_name.ptr, wide.ptr) == 0) { + return error.EnvironmentUpdateFailed; + } + } + + fn createStartupHandoffEvent(self: *Supervisor, lock_name: []const u8) !void { + const name = try std.fmt.allocPrint(self.allocator, "{s}-startup-child-ready", .{lock_name}); + defer self.allocator.free(name); + const wide = try utf16(self.allocator, name); + defer self.allocator.free(wide); + self.startup_handoff_event = c.CreateEventW(null, 1, 0, wide.ptr); + if (self.startup_handoff_event == null) return error.EventCreationFailed; + if (c.GetLastError() == c.ERROR_ALREADY_EXISTS) { + _ = c.CloseHandle(self.startup_handoff_event); + self.startup_handoff_event = null; + return error.EventCreationFailed; + } + _ = c.ResetEvent(self.startup_handoff_event); + const env_name = try utf16(self.allocator, "GRAPHCODE_DAEMON_HANDOFF_READY_EVENT"); + defer self.allocator.free(env_name); + if (c.SetEnvironmentVariableW(env_name.ptr, wide.ptr) == 0) { + return error.EnvironmentUpdateFailed; + } + } + + fn clearChildEnvironmentVariable(name: []const u8) void { + const env_name = utf16(std.heap.page_allocator, name) catch return; + defer std.heap.page_allocator.free(env_name); + _ = c.SetEnvironmentVariableW(env_name.ptr, null); + } + + fn clearDaemonChildEnvironment(self: *Supervisor) void { + _ = self; + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_STARTUP_EVENT"); + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_HANDOFF_READY_EVENT"); + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_SHUTDOWN_EVENT"); + } + + fn closeStartupEvent(self: *Supervisor) void { + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_STARTUP_EVENT"); + if (self.startup_event != null) _ = c.CloseHandle(self.startup_event); + self.startup_event = null; + } + + fn closeStartupHandoffEvent(self: *Supervisor) void { + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_HANDOFF_READY_EVENT"); + if (self.startup_handoff_event != null) _ = c.CloseHandle(self.startup_handoff_event); + self.startup_handoff_event = null; + } + + fn closeShutdownEvent(self: *Supervisor) void { + clearChildEnvironmentVariable("GRAPHCODE_DAEMON_SHUTDOWN_EVENT"); + if (self.shutdown_event != null) _ = c.CloseHandle(self.shutdown_event); + self.shutdown_event = null; + } + + fn closeProcess(self: *Supervisor) void { + if (self.process != null) _ = c.CloseHandle(self.process); + self.process = null; + self.owned = false; + } + + fn cleanupFailedStartup(self: *Supervisor) void { + self.forceStop(); + self.closeStartupHandoffEvent(); + self.closeStartupEvent(); + self.closeShutdownEvent(); + } + + fn waitForChildHandoff(self: *Supervisor) bool { + if (self.startup_handoff_event == null) return false; + switch (c.WaitForSingleObject(self.startup_handoff_event, 5_000)) { + c.WAIT_OBJECT_0 => {}, + else => return false, + } + return self.process != null and c.WaitForSingleObject(self.process, 0) == c.WAIT_TIMEOUT; + } + + fn setFailure(self: *Supervisor, message: []const u8) void { + if (self.failure.len != 0) self.allocator.free(self.failure); + self.failure = self.allocator.dupe(u8, message) catch &.{}; + } +}; + +fn probeEndpoint(endpoint: []const u8) Probe { + const wide = utf16(std.heap.page_allocator, endpoint) catch return .unknown; + defer std.heap.page_allocator.free(wide); + if (c.WaitNamedPipeW(wide.ptr, 250) != 0) return .available; + return switch (c.GetLastError()) { + c.ERROR_FILE_NOT_FOUND => .missing, + c.ERROR_SEM_TIMEOUT, c.ERROR_PIPE_BUSY => .busy, + else => .unknown, + }; +} + +fn daemonLockExists(name: []const u8) bool { + const wide = utf16(std.heap.page_allocator, name) catch return false; + defer std.heap.page_allocator.free(wide); + const handle = c.OpenMutexW(c.SYNCHRONIZE, 0, wide.ptr); + if (handle == null) return false; + _ = c.CloseHandle(handle); + return true; +} + +fn siblingDaemon(allocator: std.mem.Allocator) ![]u8 { + const self_path = try std.fs.selfExePathAlloc(allocator); + defer allocator.free(self_path); + return std.fs.path.join(allocator, &.{ std.fs.path.dirname(self_path) orelse ".", "graphcoded.exe" }); +} + +fn utf16(allocator: std.mem.Allocator, value: []const u8) ![:0]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result[0..raw.len :0]; +} + +test "busy endpoint is never treated as missing" { + try std.testing.expect(@intFromEnum(Probe.busy) != @intFromEnum(Probe.missing)); +} + +test "daemon supervisor preserves Unicode sibling paths" { + const path = try siblingDaemon(std.testing.allocator); + defer std.testing.allocator.free(path); + try std.testing.expect(std.mem.endsWith(u8, path, "graphcoded.exe")); +} + +test "failed startup competitor releases reservation for owned recovery" { + const suffix = std.time.nanoTimestamp(); + const lock_name = try std.fmt.allocPrint( + std.testing.allocator, + "Local\\graphcode-supervisor-test-{d}", + .{suffix}, + ); + defer std.testing.allocator.free(lock_name); + const endpoint = try std.fmt.allocPrint( + std.testing.allocator, + "\\\\.\\pipe\\graphcode-supervisor-test-{d}", + .{suffix}, + ); + defer std.testing.allocator.free(endpoint); + const reservation_name = try std.fmt.allocPrint( + std.testing.allocator, + "{s}-startup", + .{lock_name}, + ); + defer std.testing.allocator.free(reservation_name); + const wide = try utf16(std.testing.allocator, reservation_name); + defer std.testing.allocator.free(wide); + const competitor = c.CreateMutexW(null, 1, wide.ptr); + try std.testing.expect(competitor != null); + + const ReleaseCompetitor = struct { + fn run(handle: c.HANDLE) void { + std.Thread.sleep(50 * std.time.ns_per_ms); + _ = c.ReleaseMutex(handle); + _ = c.CloseHandle(handle); + } + }; + var thread = try std.Thread.spawn(.{}, ReleaseCompetitor.run, .{competitor}); + defer thread.join(); + + var supervisor = Supervisor{ .allocator = std.testing.allocator }; + try std.testing.expect(try supervisor.acquireStartupReservationBounded(lock_name)); + defer supervisor.releaseStartupReservation(); + try std.testing.expect(supervisor.startup_reservation != null); + try std.testing.expectEqual(Probe.missing, probeEndpoint(endpoint)); + try std.testing.expect(!daemonLockExists(lock_name)); +} + +test "concurrent shells retain startup reservation through child lifetime handoff" { + const suffix = std.time.nanoTimestamp(); + const lock_name = try std.fmt.allocPrint( + std.testing.allocator, + "Local\\graphcode-supervisor-handoff-{d}", + .{suffix}, + ); + defer std.testing.allocator.free(lock_name); + const reservation_name = try std.fmt.allocPrint( + std.testing.allocator, + "{s}-startup", + .{lock_name}, + ); + defer std.testing.allocator.free(reservation_name); + const wide_reservation = try utf16(std.testing.allocator, reservation_name); + defer std.testing.allocator.free(wide_reservation); + const wide_lifetime = try utf16(std.testing.allocator, lock_name); + defer std.testing.allocator.free(wide_lifetime); + const parent_reservation = c.CreateMutexW(null, 1, wide_reservation.ptr); + try std.testing.expect(parent_reservation != null); + + const child_ready = c.CreateEventW(null, 1, 0, null); + try std.testing.expect(child_ready != null); + defer _ = c.CloseHandle(child_ready); + const child_release = c.CreateEventW(null, 1, 0, null); + try std.testing.expect(child_release != null); + defer _ = c.CloseHandle(child_release); + + const Child = struct { + fn run( + lifetime_name: [*:0]const u16, + ready_event: c.HANDLE, + release_event: c.HANDLE, + ) void { + const lifetime = c.CreateMutexW(null, 1, lifetime_name); + if (lifetime == null) return; + _ = c.SetEvent(ready_event); + _ = c.WaitForSingleObject(release_event, c.INFINITE); + _ = c.ReleaseMutex(lifetime); + _ = c.CloseHandle(lifetime); + } + }; + var child = try std.Thread.spawn( + .{}, + Child.run, + .{ wide_lifetime.ptr, child_ready, child_release }, + ); + defer child.join(); + + try std.testing.expectEqual(c.WAIT_OBJECT_0, c.WaitForSingleObject(child_ready, 5_000)); + try std.testing.expect(daemonLockExists(lock_name)); + + _ = c.ReleaseMutex(parent_reservation); + _ = c.CloseHandle(parent_reservation); + var contender = Supervisor{ .allocator = std.testing.allocator }; + try std.testing.expect(try contender.acquireStartupReservationBounded(lock_name)); + defer contender.releaseStartupReservation(); + try std.testing.expect(daemonLockExists(lock_name)); + + _ = c.SetEvent(child_release); +} diff --git a/graphcode-windows/src/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig new file mode 100644 index 00000000..64161c5f --- /dev/null +++ b/graphcode-windows/src/DesignTokens.zig @@ -0,0 +1,33 @@ +pub const Color = u32; + +pub const window_tone: Color = 0x001E1E1E; +pub const window_background: Color = 0x8C1E1E1E; +pub const canvas_background: Color = 0x9E181818; +pub const canvas_tone: Color = 0x00181818; +pub const canvas_grid_line: Color = 0x00272727; +pub const canvas_edge: Color = 0x006A6A6A; +pub const canvas_selection: Color = 0x007AB8FF; +pub const unfocused_pane_veil: Color = 0x591E1E1E; +pub const terminal_background_opacity: f32 = 0.80; +pub const workspace_rail: Color = 0x001D1D21; +pub const pane_focus_tint: Color = 0x000A84FF; + +pub const loop_card_width: i32 = 250; +pub const loop_card_height: i32 = 106; +pub const loop_card_radius: i32 = 11; +pub const loop_card_stripe: i32 = 4; +pub const workspace_rail_width: i32 = 212; +pub const loop_bar_height: i32 = 46; +pub const loop_detail_width: i32 = 272; +pub const pane_header_height: i32 = 22; +pub const tab_bar_height: i32 = 30; +pub const canvas_grid_cell: i32 = 24; + +pub const sidebar_width: i32 = 220; +pub const header_height: i32 = 34; +pub const workspace_height: i32 = 250; +pub const activity_strip_height: i32 = 48; + +pub fn rgb(color: Color) u32 { + return color & 0x00FFFFFF; +} diff --git a/graphcode-windows/src/FolderPicker.c b/graphcode-windows/src/FolderPicker.c new file mode 100644 index 00000000..be7d5eba --- /dev/null +++ b/graphcode-windows/src/FolderPicker.c @@ -0,0 +1,49 @@ +#include "FolderPicker.h" + +#include + +int graphcode_pick_folder(HWND owner, wchar_t *buffer, DWORD capacity) { + HRESULT initialized = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(initialized) && initialized != RPC_E_CHANGED_MODE) return -1; + + IFileOpenDialog *dialog = NULL; + HRESULT result = CoCreateInstance( + &CLSID_FileOpenDialog, NULL, CLSCTX_INPROC_SERVER, + &IID_IFileOpenDialog, (void **)&dialog); + if (FAILED(result)) { + if (SUCCEEDED(initialized)) CoUninitialize(); + return -1; + } + + DWORD options = 0; + dialog->lpVtbl->GetOptions(dialog, &options); + dialog->lpVtbl->SetOptions( + dialog, options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST); + dialog->lpVtbl->SetTitle(dialog, L"Open GraphCode folder or Git repository"); + result = dialog->lpVtbl->Show(dialog, owner); + if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return 0; + } + if (FAILED(result)) { + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return -1; + } + + IShellItem *item = NULL; + result = dialog->lpVtbl->GetResult(dialog, &item); + if (SUCCEEDED(result)) { + PWSTR path = NULL; + result = item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &path); + if (SUCCEEDED(result) && path != NULL) { + lstrcpynW(buffer, path, (int)capacity); + CoTaskMemFree(path); + } + item->lpVtbl->Release(item); + } + dialog->lpVtbl->Release(dialog); + if (SUCCEEDED(initialized)) CoUninitialize(); + return SUCCEEDED(result) ? 1 : -1; +} diff --git a/graphcode-windows/src/FolderPicker.h b/graphcode-windows/src/FolderPicker.h new file mode 100644 index 00000000..ad4712ee --- /dev/null +++ b/graphcode-windows/src/FolderPicker.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +int graphcode_pick_folder(HWND owner, wchar_t *buffer, DWORD capacity); diff --git a/graphcode-windows/src/Forms.zig b/graphcode-windows/src/Forms.zig new file mode 100644 index 00000000..959712e1 --- /dev/null +++ b/graphcode-windows/src/Forms.zig @@ -0,0 +1,802 @@ +const std = @import("std"); +const GraphModel = @import("GraphModel.zig"); + +pub const NodeDraft = struct { + title: []const u8, + loop_type: []const u8 = "turnBased", + check_description: []const u8 = "", + trigger_prompt: []const u8 = "", + first_instruction: []const u8 = "Work on the requested Windows shell task.", + pauses_before_writes_only: bool = false, + goal_summary: []const u8 = "", + goal_predicate: []const u8 = "", + poll_interval_seconds: f64 = 60, + stall_after_seconds: ?f64 = null, + metric_command: []const u8 = "", + metric_direction: []const u8 = "maximize", + backend: ?[]const u8 = null, + model_tier: []const u8 = "", + worktree_repository: []const u8 = "", + worktree_id: []const u8 = "", + worktree_path: []const u8 = "", + worktree_branch: []const u8 = "", + subgraph_json: []const u8 = "", + created_by: []const u8 = "", + claude_permissions: []const u8 = "auto", + copilot_permissions: []const u8 = "allowEverything", + briefing_enabled: bool = true, + activity_enabled: bool = false, + pub fn deinit(self: *NodeDraft, allocator: std.mem.Allocator) void { + freeSlice(allocator, self.title); + freeSlice(allocator, self.loop_type); + freeSlice(allocator, self.check_description); + freeSlice(allocator, self.trigger_prompt); + freeSlice(allocator, self.first_instruction); + freeSlice(allocator, self.goal_summary); + freeSlice(allocator, self.goal_predicate); + freeSlice(allocator, self.metric_command); + freeSlice(allocator, self.metric_direction); + if (self.backend) |value| freeSlice(allocator, value); + freeSlice(allocator, self.model_tier); + freeSlice(allocator, self.worktree_repository); + freeSlice(allocator, self.worktree_id); + freeSlice(allocator, self.worktree_path); + freeSlice(allocator, self.worktree_branch); + freeSlice(allocator, self.subgraph_json); + freeSlice(allocator, self.created_by); + } +}; + +pub const EdgeDraft = struct { + from: []const u8, + to: []const u8, + kind: []const u8 = "handoff", + condition: []const u8 = "always", + transform_kind: []const u8 = "none", + transform_value: []const u8 = "", + cycle_max_iterations: ?i64 = null, + cycle_until: []const u8 = "", + cycle_stop_after_passes: ?i64 = null, + spawn_target_project_path: []const u8 = "", + + pub fn deinit(self: *EdgeDraft, allocator: std.mem.Allocator) void { + freeSlice(allocator, self.from); + freeSlice(allocator, self.to); + freeSlice(allocator, self.kind); + freeSlice(allocator, self.condition); + freeSlice(allocator, self.transform_kind); + freeSlice(allocator, self.transform_value); + freeSlice(allocator, self.cycle_until); + freeSlice(allocator, self.spawn_target_project_path); + } +}; + +fn freeSlice(allocator: std.mem.Allocator, value: []const u8) void { + if (value.len != 0) allocator.free(value); +} + +pub const NodeUpdate = struct { + goal_summary: ?[]const u8 = null, + goal_predicate: ?[]const u8 = null, + poll_interval_seconds: ?f64 = null, + stall_after_seconds: ?f64 = null, + metric_command: ?[]const u8 = null, + metric_direction: ?[]const u8 = null, + trigger_prompt: ?[]const u8 = null, + check_description: ?[]const u8 = null, + model_tier: ?[]const u8 = null, + + pub fn deinit(self: *NodeUpdate, allocator: std.mem.Allocator) void { + if (self.goal_summary) |value| allocator.free(value); + if (self.goal_predicate) |value| allocator.free(value); + if (self.metric_command) |value| allocator.free(value); + if (self.metric_direction) |value| allocator.free(value); + if (self.trigger_prompt) |value| allocator.free(value); + if (self.check_description) |value| allocator.free(value); + if (self.model_tier) |value| allocator.free(value); + } +}; + +pub const Settings = struct { + daemon_pipe: []const u8 = "", + support_directory: []const u8 = "", + reconnect_automatically: bool = true, +}; + +pub const FormError = error{ + EmptyTitle, + MissingSource, + MissingTarget, + SameEndpoint, + UnsupportedLoopType, + UnsupportedEdgeKind, + UnsupportedEdgeCondition, + UnsupportedTransform, + UnsupportedBackend, + UnsupportedModelTier, + UnsupportedMetricDirection, + InvalidGoal, + InvalidWorktree, + InvalidSubgraph, + InvalidCreatedBy, + InvalidCycleGuard, + InvalidNumericInput, + MissingFirstInstruction, + MissingTriggerPrompt, + EmptyJumpQuery, +}; + +pub const untitled_fallback = "New Loop"; + +pub fn resolvedTitle(title: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, title, " \t\r\n"); + return if (trimmed.len == 0) untitled_fallback else trimmed; +} + +pub fn validateJumpQuery(query: []const u8) FormError![]const u8 { + const trimmed = std.mem.trim(u8, query, " \t\r\n"); + if (trimmed.len == 0) return error.EmptyJumpQuery; + return trimmed; +} + +pub fn validateNode(draft: NodeDraft) FormError!void { + if (!isLoopType(draft.loop_type) and !std.mem.eql(u8, draft.loop_type, "composite")) + return error.UnsupportedLoopType; + if (std.mem.eql(u8, draft.loop_type, "composite") and + std.mem.trim(u8, draft.title, " \t\r\n").len == 0) + return error.EmptyTitle; + if (draft.backend) |backend| { + if (!isBackend(backend)) + return error.UnsupportedBackend; + } + if (draft.model_tier.len != 0 and + !isModelTier(draft.model_tier)) + return error.UnsupportedModelTier; + if (std.mem.eql(u8, draft.loop_type, "turnBased") and + std.mem.trim(u8, draft.first_instruction, " \t\r\n").len == 0) + return error.MissingFirstInstruction; + if (std.mem.eql(u8, draft.loop_type, "timeBased") and + std.mem.trim(u8, draft.trigger_prompt, " \t\r\n").len == 0) + return error.MissingTriggerPrompt; + if (std.mem.eql(u8, draft.loop_type, "goalBased")) { + if (std.mem.trim(u8, draft.goal_summary, " \t\r\n").len == 0) return error.InvalidGoal; + if (draft.poll_interval_seconds <= 0) return error.InvalidGoal; + if (draft.stall_after_seconds) |seconds| if (seconds <= 0) return error.InvalidGoal; + if (draft.metric_direction.len != 0 and + !isMetricDirection(draft.metric_direction)) + return error.UnsupportedMetricDirection; + } + const has_worktree = draft.worktree_repository.len != 0 or + draft.worktree_path.len != 0 or draft.worktree_branch.len != 0; + if (has_worktree and (draft.worktree_repository.len == 0 or + draft.worktree_path.len == 0 or draft.worktree_branch.len == 0)) + return error.InvalidWorktree; + if (draft.subgraph_json.len != 0) try validateSubgraphJson(draft.subgraph_json); + if (draft.created_by.len != 0 and !isUuid(draft.created_by)) return error.InvalidCreatedBy; +} + +pub fn validateSubgraphJson(value: []const u8) FormError!void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + var parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), value, .{}) catch return error.InvalidSubgraph; + defer parsed.deinit(); + canonicalizeLoopTypeAliases(&parsed.value); + try validateLoopGraphValue(parsed.value, 0); +} + +pub fn isLoopType(value: []const u8) bool { + return std.mem.eql(u8, value, "turnBased") or + std.mem.eql(u8, value, "timeBased") or + std.mem.eql(u8, value, "goalBased") or + std.mem.eql(u8, value, "proactive"); +} + +pub fn canonicalizeLoopTypeAliases(value: *std.json.Value) void { + switch (value.*) { + .object => |*object| { + if (object.getPtr("loopType")) |loop_type| switch (loop_type.*) { + .string => |text| if (std.mem.eql(u8, text, "composite")) { + loop_type.* = .{ .string = "proactive" }; + }, + else => {}, + }; + if (object.getPtr("subGraph")) |nested| canonicalizeLoopTypeAliases(nested); + if (object.getPtr("nodes")) |nodes| switch (nodes.*) { + .array => |*items| for (items.items) |*item| canonicalizeLoopTypeAliases(item), + else => {}, + }; + }, + .array => |*items| for (items.items) |*item| canonicalizeLoopTypeAliases(item), + else => {}, + } +} + +pub fn isBackend(value: []const u8) bool { + return std.mem.eql(u8, value, "claudeCode") or + std.mem.eql(u8, value, "copilotCLI") or + std.mem.eql(u8, value, "codex"); +} + +pub fn isModelTier(value: []const u8) bool { + return std.mem.eql(u8, value, "fast") or + std.mem.eql(u8, value, "standard") or + std.mem.eql(u8, value, "capable"); +} + +pub fn isMetricDirection(value: []const u8) bool { + return std.mem.eql(u8, value, "maximize") or std.mem.eql(u8, value, "minimize"); +} + +pub fn isEdgeKind(value: []const u8) bool { + return std.mem.eql(u8, value, "handoff") or + std.mem.eql(u8, value, "message") or + std.mem.eql(u8, value, "spawn"); +} + +pub fn isEdgeCondition(value: []const u8) bool { + return std.mem.eql(u8, value, "always") or + std.mem.eql(u8, value, "onSuccess") or + std.mem.eql(u8, value, "onFailure"); +} + +pub fn isTransformKind(value: []const u8) bool { + return std.mem.eql(u8, value, "none") or + std.mem.eql(u8, value, "template") or + std.mem.eql(u8, value, "script"); +} + +fn validateLoopGraphValue(value: std.json.Value, depth: usize) FormError!void { + if (depth > 32) return error.InvalidSubgraph; + const object = switch (value) { + .object => |item| item, + else => return error.InvalidSubgraph, + }; + const graph_id = object.get("id") orelse return error.InvalidSubgraph; + if (!isUuidValue(graph_id)) return error.InvalidSubgraph; + const project = object.get("project") orelse return error.InvalidSubgraph; + const project_object = switch (project) { + .object => |item| item, + else => return error.InvalidSubgraph, + }; + if (!nonEmptyString(project_object.get("path")) or + !nonEmptyString(project_object.get("name")) or + !isDateValue(project_object.get("lastOpenedAt"))) + return error.InvalidSubgraph; + const nodes = object.get("nodes") orelse return error.InvalidSubgraph; + const edges = object.get("edges") orelse return error.InvalidSubgraph; + const node_items = switch (nodes) { + .array => |items| items, + else => return error.InvalidSubgraph, + }; + const edge_items = switch (edges) { + .array => |items| items, + else => return error.InvalidSubgraph, + }; + for (node_items.items) |item| { + const node = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!isUuidValue(node.get("id")) or + !nonEmptyString(node.get("title")) or + !enumString(node.get("loopType"), isLoopType)) + return error.InvalidSubgraph; + try optionalString(node.get("checkDescription")); + try optionalString(node.get("triggerPrompt")); + try optionalString(node.get("firstInstruction")); + try requiredBool(node.get("pausesBeforeWritesOnly")); + try validateGoal(node.get("goal")); + if (!enumString(node.get("backend"), isBackend)) return error.InvalidSubgraph; + if (!enumStringOrNull(node.get("modelTier"), isModelTier)) return error.InvalidSubgraph; + try validateWorktree(node.get("worktreeBinding")); + const subgraph = node.get("subGraph") orelse null; + if (subgraph) |nested| switch (nested) { + .null => {}, + else => try validateLoopGraphValue(nested, depth + 1), + }; + if (!enumString(node.get("pilotState"), isPilotState)) return error.InvalidSubgraph; + try validateUsage(node.get("usage")); + try optionalString(node.get("activity")); + try validatePresence(node.get("presence")); + // Runtime-only and intentionally ignored by Codable persistence. + try validateMetricHistory(node.get("metricHistory")); + if (!isUuidOrNull(node.get("createdBy"))) return error.InvalidSubgraph; + if (!enumObject(node.get("state"), isLoopState)) return error.InvalidSubgraph; + if (!isDateValue(node.get("createdAt"))) return error.InvalidSubgraph; + } + for (edge_items.items) |item| { + const edge = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!isUuidValue(edge.get("id")) or + !isUuidValue(edge.get("from")) or + !isUuidValue(edge.get("to")) or + !enumString(edge.get("kind"), isEdgeKind) or + !enumString(edge.get("condition"), isEdgeCondition)) + return error.InvalidSubgraph; + try validatePayloadTransform(edge.get("payloadTransform")); + try validateCycleGuard(edge.get("cycleGuard")); + try optionalString(edge.get("spawnTargetProjectPath")); + try optionalInteger(edge.get("fireCount")); + } +} + +fn validateGoal(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item == .null) return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!nonEmptyString(object.get("summary")) or + !isPositiveNumber(object.get("pollIntervalSeconds"))) + return error.InvalidSubgraph; + try optionalString(object.get("predicate")); + try optionalNumber(object.get("stallAfterSeconds")); + try optionalString(object.get("metricCommand")); + if (!enumString(object.get("metricDirection"), isMetricDirection)) return error.InvalidSubgraph; +} + +fn validateWorktree(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item == .null) return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!nonEmptyString(object.get("id")) or + !nonEmptyString(object.get("repositoryPath")) or + !nonEmptyString(object.get("worktreePath")) or + !nonEmptyString(object.get("branch"))) + return error.InvalidSubgraph; +} + +fn validatePayloadTransform(value: ?std.json.Value) FormError!void { + const item = value orelse return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + var count: usize = 0; + if (object.get("none")) |none| { + count += 1; + if (none != .object) return error.InvalidSubgraph; + } + for ([_][]const u8{ "template", "script" }) |key| { + if (object.get(key)) |payload| { + count += 1; + if (!transformPayloadValue(payload)) return error.InvalidSubgraph; + } + } + if (count != 1) return error.InvalidSubgraph; +} + +fn transformPayloadValue(value: std.json.Value) bool { + return switch (value) { + .object => |object| nonEmptyString(object.get("_0")), + .string => |text| std.mem.trim(u8, text, " \t\r\n").len != 0, + else => false, + }; +} + +fn validateCycleGuard(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item == .null) return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + var bounded = false; + if (object.get("maxIterations")) |field| { + if (field == .null) {} else { + if (!isPositiveInteger(field)) return error.InvalidSubgraph; + bounded = true; + } + } + if (object.get("until")) |field| { + if (field == .null) {} else { + if (!nonEmptyStringValue(field)) return error.InvalidSubgraph; + bounded = true; + } + } + if (object.get("stopAfterPassesWithoutImprovement")) |field| { + if (field == .null) {} else { + if (!isPositiveInteger(field)) return error.InvalidSubgraph; + bounded = true; + } + } + if (!bounded) return error.InvalidSubgraph; +} + +fn validateUsage(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item == .null) return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + try optionalInteger(object.get("inputTokens")); + try optionalInteger(object.get("outputTokens")); + try optionalNumber(object.get("costUSD")); + try optionalDate(object.get("reportedAt")); +} + +fn validatePresence(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item == .null) return; + const object = switch (item) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!enumString(object.get("presence"), isPresence) or + !enumString(object.get("confidence"), isPresenceConfidence)) + return error.InvalidSubgraph; +} + +fn validateMetricHistory(value: ?std.json.Value) FormError!void { + const item = value orelse return; + const array = switch (item) { + .array => |items| items, + else => return error.InvalidSubgraph, + }; + for (array.items) |entry| { + const object = switch (entry) { + .object => |value_object| value_object, + else => return error.InvalidSubgraph, + }; + if (!isFiniteNumber(object.get("value")) or !isDateValue(object.get("recordedAt"))) + return error.InvalidSubgraph; + } +} + +fn isPilotState(value: []const u8) bool { + return std.mem.eql(u8, value, "notPiloted") or std.mem.eql(u8, value, "piloting") or + std.mem.eql(u8, value, "piloted") or std.mem.eql(u8, value, "armed"); +} + +fn isLoopState(value: []const u8) bool { + return std.mem.eql(u8, value, "idle") or std.mem.eql(u8, value, "running") or + std.mem.eql(u8, value, "awaitingInput") or std.mem.eql(u8, value, "blocked") or + std.mem.eql(u8, value, "succeeded") or std.mem.eql(u8, value, "failed") or + std.mem.eql(u8, value, "stalled") or std.mem.eql(u8, value, "waiting") or + std.mem.eql(u8, value, "stopped"); +} + +fn isPresence(value: []const u8) bool { + return std.mem.eql(u8, value, "busy") or std.mem.eql(u8, value, "awaitingInput") or + std.mem.eql(u8, value, "idle") or std.mem.eql(u8, value, "absent") or + std.mem.eql(u8, value, "unknown"); +} + +fn isPresenceConfidence(value: []const u8) bool { + return std.mem.eql(u8, value, "reported") or std.mem.eql(u8, value, "scanned") or + std.mem.eql(u8, value, "heuristic"); +} + +fn enumString(value: ?std.json.Value, validator: *const fn ([]const u8) bool) bool { + return switch (value orelse return false) { + .string => |text| validator(text), + else => false, + }; +} + +fn enumStringOrNull(value: ?std.json.Value, validator: *const fn ([]const u8) bool) bool { + const item = value orelse return true; + return switch (item) { + .null => true, + .string => |text| validator(text), + else => false, + }; +} + +fn enumObject(value: ?std.json.Value, validator: *const fn ([]const u8) bool) bool { + const item = value orelse return false; + const object = switch (item) { + .object => |value_object| value_object, + else => return false, + }; + var count: usize = 0; + var valid = false; + var iterator = object.iterator(); + while (iterator.next()) |entry| { + count += 1; + if (validator(entry.key_ptr.*)) { + valid = entry.value_ptr.* == .object; + } + } + return count == 1 and valid; +} + +fn optionalString(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item != .null and item != .string) return error.InvalidSubgraph; +} + +fn optionalBool(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item != .null and item != .bool) return error.InvalidSubgraph; +} + +fn requiredBool(value: ?std.json.Value) FormError!void { + const item = value orelse return error.InvalidSubgraph; + if (item != .bool) return error.InvalidSubgraph; +} + +fn optionalNumber(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item != .null and !isFiniteNumber(item)) return error.InvalidSubgraph; +} + +fn optionalInteger(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item != .null and item != .integer) return error.InvalidSubgraph; +} + +fn optionalDate(value: ?std.json.Value) FormError!void { + const item = value orelse return; + if (item != .null and !isDateValue(item)) return error.InvalidSubgraph; +} + +fn isUuidOrNull(value: ?std.json.Value) bool { + const item = value orelse return true; + return item == .null or isUuidValue(item); +} + +fn isPositiveNumber(value: ?std.json.Value) bool { + return switch (value orelse return false) { + .integer => |number| number > 0, + .float => |number| std.math.isFinite(number) and number > 0, + else => false, + }; +} + +fn isPositiveInteger(value: std.json.Value) bool { + return switch (value) { + .integer => |number| number > 0, + else => false, + }; +} + +fn isFiniteNumber(value: ?std.json.Value) bool { + return switch (value orelse return false) { + .integer => true, + .float => |number| std.math.isFinite(number), + else => false, + }; +} + +fn nonEmptyStringValue(value: std.json.Value) bool { + return switch (value) { + .string => |text| std.mem.trim(u8, text, " \t\r\n").len != 0, + else => false, + }; +} + +pub fn isUuid(value: []const u8) bool { + if (value.len != 36) return false; + for (value, 0..) |byte, index| { + if (index == 8 or index == 13 or index == 18 or index == 23) { + if (byte != '-') return false; + } else if (!std.ascii.isHex(byte)) return false; + } + return true; +} + +fn isUuidValue(value: ?std.json.Value) bool { + return switch (value orelse return false) { + .string => |text| isUuid(text), + else => false, + }; +} + +fn nonEmptyString(value: ?std.json.Value) bool { + return switch (value orelse return false) { + .string => |text| std.mem.trim(u8, text, " \t\r\n").len != 0, + else => false, + }; +} + +fn isDateValue(value: ?std.json.Value) bool { + return switch (value orelse return false) { + .float, .integer => true, + else => false, + }; +} + +pub fn validateEdge(draft: EdgeDraft) FormError!void { + if (draft.from.len == 0) return error.MissingSource; + if (draft.to.len == 0) return error.MissingTarget; + if (std.mem.eql(u8, draft.from, draft.to)) return error.SameEndpoint; + if (!isEdgeKind(draft.kind)) + return error.UnsupportedEdgeKind; + if (!isEdgeCondition(draft.condition)) + return error.UnsupportedEdgeCondition; + if (!isTransformKind(draft.transform_kind)) + return error.UnsupportedTransform; + if (!std.mem.eql(u8, draft.transform_kind, "none") and draft.transform_value.len == 0) + return error.UnsupportedTransform; + if (draft.cycle_max_iterations) |count| if (count <= 0) return error.InvalidCycleGuard; + if (draft.cycle_stop_after_passes) |count| if (count <= 0) return error.InvalidCycleGuard; +} + +pub fn validateNodeUpdate(update: NodeUpdate) FormError!void { + if (update.goal_summary) |value| if (std.mem.trim(u8, value, " \t\r\n").len == 0) return error.InvalidGoal; + if (update.poll_interval_seconds) |value| if (value <= 0) return error.InvalidGoal; + if (update.stall_after_seconds) |value| { + // Zero or negative is the explicit clear sentinel used by NodeUpdate. + if (!std.math.isFinite(value)) return error.InvalidGoal; + } + if (update.metric_direction) |value| + if (!std.mem.eql(u8, value, "maximize") and !std.mem.eql(u8, value, "minimize")) + return error.UnsupportedMetricDirection; + if (update.model_tier) |value| + if (!std.mem.eql(u8, value, "fast") and !std.mem.eql(u8, value, "standard") and !std.mem.eql(u8, value, "capable")) + return error.UnsupportedModelTier; + if (update.goal_summary == null and update.goal_predicate == null and update.poll_interval_seconds == null and + update.stall_after_seconds == null and update.metric_command == null and update.metric_direction == null and + update.trigger_prompt == null and update.check_description == null and update.model_tier == null) + return error.InvalidGoal; +} + +pub fn jumpTo(nodes: []const GraphModel.Node, query: []const u8, current: ?usize) ?usize { + if (nodes.len == 0) return null; + const needle = std.mem.trim(u8, query, " \t\r\n"); + if (needle.len == 0) return current orelse 0; + const start = (current orelse nodes.len - 1) + 1; + var offset: usize = 0; + while (offset < nodes.len) : (offset += 1) { + const index = (start + offset) % nodes.len; + if (containsIgnoreCase(nodes[index].title, needle) or + containsIgnoreCase(nodes[index].id, needle)) + return index; + } + return null; +} + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + var start: usize = 0; + while (start + needle.len <= haystack.len) : (start += 1) { + var equal = true; + for (needle, 0..) |byte, index| { + if (std.ascii.toLower(haystack[start + index]) != std.ascii.toLower(byte)) { + equal = false; + break; + } + } + if (equal) return true; + } + return false; +} + +pub const ContextCommand = enum { + create_node, + edit_node, + create_edge, + open_node, + stop_node, + delete_node, + jump, + settings, +}; + +test "node and edge forms reject invalid drafts explicitly" { + try std.testing.expectError(error.EmptyTitle, validateNode(.{ .title = " \n", .loop_type = "composite" })); + try validateNode(.{ .title = " \n", .loop_type = "turnBased" }); + try std.testing.expectEqualStrings("New Loop", resolvedTitle(" \n")); + try std.testing.expectError(error.SameEndpoint, validateEdge(.{ .from = "a", .to = "a" })); + try std.testing.expectError(error.UnsupportedEdgeKind, validateEdge(.{ .from = "a", .to = "b", .kind = "bad" })); + try validateNode(.{ .title = "Composite", .loop_type = "composite", .subgraph_json = "{\"id\":\"33333333-3333-4333-8333-333333333333\",\"project\":{\"path\":\"C:\\\\work\\\\subgraph\",\"name\":\"subgraph\",\"lastOpenedAt\":1767225600},\"nodes\":[],\"edges\":[]}", .created_by = "11111111-1111-4111-8111-111111111111" }); + try std.testing.expectError(error.InvalidSubgraph, validateNode(.{ .title = "Composite", .loop_type = "composite", .subgraph_json = "{\"nodes\":[]}" })); + try std.testing.expectError(error.InvalidCreatedBy, validateNode(.{ .title = "Loop", .created_by = "not-a-uuid" })); +} + +test "node updates preserve unchanged fields and allow stall clear sentinel" { + try validateNodeUpdate(.{ .stall_after_seconds = 0 }); + try std.testing.expectError(error.InvalidGoal, validateNodeUpdate(.{ .poll_interval_seconds = 0 })); +} + +test "typed form result deinit releases every owned allocation" { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer std.testing.expect(gpa.deinit() == .ok) catch unreachable; + const allocator = gpa.allocator(); + var draft = NodeDraft{ + .title = try allocator.dupe(u8, "title"), + .loop_type = try allocator.dupe(u8, "turnBased"), + .check_description = try allocator.dupe(u8, "check"), + .trigger_prompt = try allocator.dupe(u8, "trigger"), + .first_instruction = try allocator.dupe(u8, "do"), + .goal_summary = try allocator.dupe(u8, ""), + .goal_predicate = try allocator.dupe(u8, ""), + .metric_command = try allocator.dupe(u8, ""), + .metric_direction = try allocator.dupe(u8, "maximize"), + .backend = try allocator.dupe(u8, "claudeCode"), + .model_tier = try allocator.dupe(u8, ""), + .worktree_repository = try allocator.dupe(u8, ""), + .worktree_id = try allocator.dupe(u8, ""), + .worktree_path = try allocator.dupe(u8, ""), + .worktree_branch = try allocator.dupe(u8, ""), + .subgraph_json = try allocator.dupe(u8, ""), + .created_by = try allocator.dupe(u8, ""), + }; + draft.deinit(allocator); + var update = NodeUpdate{ + .goal_summary = try allocator.dupe(u8, "done"), + .goal_predicate = try allocator.dupe(u8, ""), + .metric_command = try allocator.dupe(u8, "metric"), + }; + update.deinit(allocator); + var edge = EdgeDraft{ + .from = try allocator.dupe(u8, "a"), + .to = try allocator.dupe(u8, "b"), + .kind = try allocator.dupe(u8, "handoff"), + .condition = try allocator.dupe(u8, "always"), + .transform_kind = try allocator.dupe(u8, "none"), + .transform_value = try allocator.dupe(u8, ""), + .cycle_until = try allocator.dupe(u8, ""), + .spawn_target_project_path = try allocator.dupe(u8, ""), + }; + edge.deinit(allocator); +} + +test "incremental draft construction survives every allocation failure" { + const Builder = struct { + fn node(allocator: std.mem.Allocator) !void { + var draft = NodeDraft{ .title = &.{}, .loop_type = &.{}, .check_description = &.{}, .trigger_prompt = &.{}, .first_instruction = &.{}, .goal_summary = &.{}, .goal_predicate = &.{}, .metric_command = &.{}, .metric_direction = &.{}, .model_tier = &.{}, .worktree_repository = &.{}, .worktree_id = &.{}, .worktree_path = &.{}, .worktree_branch = &.{}, .subgraph_json = &.{}, .created_by = &.{} }; + errdefer draft.deinit(allocator); + draft.title = try allocator.dupe(u8, "title"); + draft.loop_type = try allocator.dupe(u8, "turnBased"); + draft.check_description = try allocator.dupe(u8, "check"); + draft.trigger_prompt = try allocator.dupe(u8, "trigger"); + draft.first_instruction = try allocator.dupe(u8, "instruction"); + draft.goal_summary = try allocator.dupe(u8, "goal"); + draft.goal_predicate = try allocator.dupe(u8, "predicate"); + draft.metric_command = try allocator.dupe(u8, "metric"); + draft.metric_direction = try allocator.dupe(u8, "maximize"); + draft.model_tier = try allocator.dupe(u8, "standard"); + draft.worktree_repository = try allocator.dupe(u8, "repo"); + draft.worktree_id = try allocator.dupe(u8, "id"); + draft.worktree_path = try allocator.dupe(u8, "path"); + draft.worktree_branch = try allocator.dupe(u8, "branch"); + draft.subgraph_json = try allocator.dupe(u8, "{\"nodes\":[],\"edges\":[]}"); + draft.created_by = try allocator.dupe(u8, "11111111-1111-4111-8111-111111111111"); + draft.deinit(allocator); + } + + fn edge(allocator: std.mem.Allocator) !void { + var draft = EdgeDraft{ .from = &.{}, .to = &.{}, .kind = &.{}, .condition = &.{}, .transform_kind = &.{}, .transform_value = &.{}, .cycle_until = &.{}, .spawn_target_project_path = &.{} }; + errdefer draft.deinit(allocator); + draft.from = try allocator.dupe(u8, "from"); + draft.to = try allocator.dupe(u8, "to"); + draft.kind = try allocator.dupe(u8, "handoff"); + draft.condition = try allocator.dupe(u8, "always"); + draft.transform_kind = try allocator.dupe(u8, "none"); + draft.transform_value = try allocator.dupe(u8, "value"); + draft.cycle_until = try allocator.dupe(u8, "until"); + draft.spawn_target_project_path = try allocator.dupe(u8, "project"); + draft.deinit(allocator); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Builder.node, .{}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Builder.edge, .{}); +} + +test "jump navigation wraps and matches title or id case insensitively" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("node-a"), .title = @constCast("Alpha"), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + .{ .id = @constCast("node-b"), .title = @constCast("Beta"), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + try std.testing.expectEqual(@as(?usize, 1), jumpTo(&nodes, "be", 0)); + try std.testing.expectEqual(@as(?usize, 0), jumpTo(&nodes, "NODE-A", 1)); +} + +test "jump queries reject empty and whitespace input before navigation" { + try std.testing.expectError(error.EmptyJumpQuery, validateJumpQuery("")); + try std.testing.expectError(error.EmptyJumpQuery, validateJumpQuery(" \t\r\n ")); + try std.testing.expectEqualStrings("be", try validateJumpQuery(" \tbe\n")); + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("node-a"), .title = @constCast("Alpha"), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + try std.testing.expectEqual(@as(?usize, null), jumpTo(&nodes, "missing", 0)); +} diff --git a/graphcode-windows/src/FrameBuffer.zig b/graphcode-windows/src/FrameBuffer.zig new file mode 100644 index 00000000..02acd2a9 --- /dev/null +++ b/graphcode-windows/src/FrameBuffer.zig @@ -0,0 +1,128 @@ +const std = @import("std"); +const Wire = @import("Wire.zig"); + +pub const FrameBuffer = struct { + allocator: std.mem.Allocator, + mode: Wire.ProtocolMode = .v2, + bytes: []u8, + length: usize = 0, + expected_length: ?usize = null, + + pub fn init(allocator: std.mem.Allocator, mode: Wire.ProtocolMode) !FrameBuffer { + return .{ + .allocator = allocator, + .mode = mode, + .bytes = try allocator.alloc(u8, Wire.legacy_max_payload + 4), + }; + } + + pub fn deinit(self: *FrameBuffer) void { + self.allocator.free(self.bytes); + self.bytes = &.{}; + } + + pub fn capacity(self: *const FrameBuffer) usize { + return self.bytes.len; + } + + pub fn setMode(self: *FrameBuffer, mode: Wire.ProtocolMode) void { + self.mode = mode; + self.reset(); + } + + pub fn setModePreservingData(self: *FrameBuffer, mode: Wire.ProtocolMode) void { + self.mode = mode; + } + + pub fn reset(self: *FrameBuffer) void { + self.length = 0; + self.expected_length = null; + } + + pub fn append(self: *FrameBuffer, chunk: []const u8) !void { + if (chunk.len > self.bytes.len - self.length) return error.BufferOverflow; + @memcpy(self.bytes[self.length..][0..chunk.len], chunk); + self.length += chunk.len; + } + + pub fn next(self: *FrameBuffer, allocator: std.mem.Allocator) !?[]u8 { + if (self.expected_length == null) { + if (self.length < 4) return null; + var header: [4]u8 = undefined; + @memcpy(&header, self.bytes[0..4]); + self.remove(4); + self.expected_length = try Wire.decodedLength(header, self.mode); + } + const frame_length = self.expected_length.?; + if (self.length < frame_length) return null; + const frame = try allocator.alloc(u8, frame_length); + errdefer allocator.free(frame); + @memcpy(frame, self.bytes[0..frame_length]); + self.remove(frame_length); + self.expected_length = null; + return frame; + } + + fn remove(self: *FrameBuffer, count: usize) void { + const remaining = self.length - count; + if (remaining != 0) { + std.mem.copyForwards(u8, self.bytes[0..remaining], self.bytes[count..self.length]); + } + self.length = remaining; + } +}; + +test "incremental frame buffering never requires a complete read" { + const allocator = std.testing.allocator; + const payload = "split frame"; + const header = try Wire.frameLength(payload, .v2); + var buffer = try FrameBuffer.init(allocator, .v2); + defer buffer.deinit(); + try buffer.append(header[0..2]); + try std.testing.expect((try buffer.next(allocator)) == null); + try buffer.append(header[2..]); + try buffer.append(payload[0..5]); + try std.testing.expect((try buffer.next(allocator)) == null); + try buffer.append(payload[5..]); + const frame = (try buffer.next(allocator)).?; + defer allocator.free(frame); + try std.testing.expectEqualStrings(payload, frame); +} + +test "incremental frame buffering drains coalesced frames" { + const allocator = std.testing.allocator; + const first = "one"; + const second = "two"; + const first_header = try Wire.frameLength(first, .v2); + const second_header = try Wire.frameLength(second, .v2); + var buffer = try FrameBuffer.init(allocator, .v2); + defer buffer.deinit(); + try buffer.append(&first_header); + try buffer.append(first); + try buffer.append(&second_header); + try buffer.append(second); + const first_frame = (try buffer.next(allocator)).?; + defer allocator.free(first_frame); + const second_frame = (try buffer.next(allocator)).?; + defer allocator.free(second_frame); + try std.testing.expectEqualStrings(first, first_frame); + try std.testing.expectEqualStrings(second, second_frame); + try std.testing.expect((try buffer.next(allocator)) == null); +} + +test "frame storage is heap-backed and keeps startup structs small" { + const allocator = std.testing.allocator; + var buffer = try FrameBuffer.init(allocator, .v1); + defer buffer.deinit(); + try std.testing.expectEqual(Wire.legacy_max_payload + 4, buffer.capacity()); + try std.testing.expect(@sizeOf(FrameBuffer) < 1024); +} + +test "switching back to v2 restores the one MiB payload cap" { + const allocator = std.testing.allocator; + var buffer = try FrameBuffer.init(allocator, .v1); + defer buffer.deinit(); + const oversized = try Wire.frameLength(&[_]u8{0} ** (Wire.v2_max_payload + 1), .v1); + buffer.setMode(.v2); + try std.testing.expectError(error.PayloadTooLarge, Wire.decodedLength(oversized, .v2)); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig new file mode 100644 index 00000000..ecefbaf2 --- /dev/null +++ b/graphcode-windows/src/GraphCanvas.zig @@ -0,0 +1,1776 @@ +const std = @import("std"); +const GraphModel = @import("GraphModel.zig"); +const Tokens = @import("DesignTokens.zig"); +const Sidebar = @import("Sidebar.zig"); +const WorktreeStatus = @import("WorktreeStatus.zig"); +const WorkspaceControls = @import("WorkspaceControls.zig"); +const c = @import("Win32.zig").c; +pub const connection_failure_message = "GraphCode daemon unavailable. Navigation remains available while reconnection continues."; + +pub const CanvasState = struct { + const NodeOffset = struct { x: f32 = 0, y: f32 = 0 }; + + pan_x: f32 = 0, + pan_y: f32 = 0, + zoom: f32 = 1, + dragging: bool = false, + drag_x: i32 = 0, + drag_y: i32 = 0, + start_pan_x: f32 = 0, + start_pan_y: f32 = 0, + selected_edge: ?usize = null, + selected_edge_id: []const u8 = "", + edge_dragging: bool = false, + edge_drag_source_id: []const u8 = "", + edge_drag_x: i32 = 0, + edge_drag_y: i32 = 0, + node_offsets: [512]NodeOffset = [_]NodeOffset{.{}} ** 512, + node_offset_keys: [512]u64 = [_]u64{0} ** 512, + node_offset_used: [512]bool = [_]bool{false} ** 512, + node_dragging: bool = false, + node_drag_index: usize = 0, + node_drag_key: u64 = 0, + node_drag_x: i32 = 0, + node_drag_y: i32 = 0, + node_drag_origin: NodeOffset = .{}, + + pub fn beginPan(self: *CanvasState, x: i32, y: i32) void { + self.dragging = true; + self.drag_x = x; + self.drag_y = y; + self.start_pan_x = self.pan_x; + self.start_pan_y = self.pan_y; + } + + test "toolbar actions require visible contextual controls" { + const attention = headerAttentionRect(); + try std.testing.expectEqual( + HeaderAction.review_attention, + headerActionAt(attention.left + 2, attention.top + 2, 1200, true, false, false).?, + ); + try std.testing.expect(headerActionAt(attention.left + 2, attention.top + 2, 1200, false, false, false) == null); + const jump = headerJumpRect(1200); + try std.testing.expectEqual(HeaderAction.jump, headerActionAt(jump.left + 2, jump.top + 2, 1200, false, false, false).?); + const panel = headerPanelRect(1200); + try std.testing.expect(headerActionAt(panel.left + 2, panel.top + 2, 1200, false, false, false) == null); + try std.testing.expectEqual(HeaderAction.toggle_panel, headerActionAt(panel.left + 2, panel.top + 2, 1200, false, false, true).?); + } + + pub fn updatePan(self: *CanvasState, x: i32, y: i32) void { + if (!self.dragging) return; + self.pan_x = self.start_pan_x + @as(f32, @floatFromInt(x - self.drag_x)); + self.pan_y = self.start_pan_y + @as(f32, @floatFromInt(y - self.drag_y)); + } + + pub fn endPan(self: *CanvasState) void { + self.dragging = false; + } + + pub fn cancelInteraction(self: *CanvasState) void { + self.dragging = false; + self.edge_dragging = false; + self.edge_drag_source_id = ""; + if (self.node_dragging and self.node_drag_index < self.node_offsets.len) + self.node_offsets[self.node_drag_index] = self.node_drag_origin; + self.node_dragging = false; + self.node_drag_key = 0; + } + + pub fn beginEdgeDrag(self: *CanvasState, source_id: []const u8, x: i32, y: i32) void { + self.edge_dragging = true; + self.edge_drag_source_id = source_id; + self.edge_drag_x = x; + self.edge_drag_y = y; + } + + pub fn updateEdgeDrag(self: *CanvasState, x: i32, y: i32) void { + if (!self.edge_dragging) return; + self.edge_drag_x = x; + self.edge_drag_y = y; + } + + pub fn beginNodeDrag(self: *CanvasState, node_id: []const u8, index: usize, x: i32, y: i32) void { + if (index >= self.node_offsets.len) return; + self.node_dragging = true; + self.node_drag_index = index; + self.node_drag_key = nodeKey(node_id); + self.node_drag_x = x; + self.node_drag_y = y; + self.node_drag_origin = self.node_offsets[index]; + } + + pub fn updateNodeDrag(self: *CanvasState, x: i32, y: i32) void { + if (!self.node_dragging or self.node_drag_index >= self.node_offsets.len) return; + self.node_offsets[self.node_drag_index] = .{ + .x = self.node_drag_origin.x + @as(f32, @floatFromInt(x - self.node_drag_x)) / self.zoom, + .y = self.node_drag_origin.y + @as(f32, @floatFromInt(y - self.node_drag_y)) / self.zoom, + }; + } + + pub fn endNodeDrag(self: *CanvasState) void { + self.node_dragging = false; + self.node_drag_key = 0; + } + + pub fn syncNodeOffsets(self: *CanvasState, nodes: []const GraphModel.Node) void { + const old_offsets = self.node_offsets; + const old_keys = self.node_offset_keys; + const old_used = self.node_offset_used; + self.node_offsets = [_]NodeOffset{.{}} ** self.node_offsets.len; + self.node_offset_keys = [_]u64{0} ** self.node_offset_keys.len; + self.node_offset_used = [_]bool{false} ** self.node_offset_used.len; + + for (nodes[0..@min(nodes.len, self.node_offsets.len)], 0..) |node, index| { + const key = nodeKey(node.id); + self.node_offset_keys[index] = key; + self.node_offset_used[index] = true; + for (old_keys, old_used, 0..) |old_key, used, old_index| { + if (used and old_key == key) { + self.node_offsets[index] = old_offsets[old_index]; + break; + } + } + } + var next = @min(nodes.len, self.node_offsets.len); + for (old_keys, old_used, 0..) |old_key, used, old_index| { + if (!used or next >= self.node_offsets.len) continue; + var retained = false; + for (self.node_offset_keys[0..next], self.node_offset_used[0..next]) |key, current_used| { + if (current_used and key == old_key) { + retained = true; + break; + } + } + if (retained) continue; + self.node_offset_keys[next] = old_key; + self.node_offset_used[next] = true; + self.node_offsets[next] = old_offsets[old_index]; + next += 1; + } + + if (self.node_dragging) { + for (self.node_offset_keys, self.node_offset_used, 0..) |key, used, index| { + if (used and key == self.node_drag_key) { + self.node_drag_index = index; + return; + } + } + self.node_dragging = false; + self.node_drag_key = 0; + } + } + + pub fn encodeNodeOffsets(self: *const CanvasState, allocator: std.mem.Allocator) ![]u8 { + var output = std.array_list.Managed(u8).init(allocator); + errdefer output.deinit(); + for (self.node_offset_keys, self.node_offset_used, self.node_offsets) |key, used, offset| { + if (!used) continue; + try output.writer().print("{x}\t{d}\t{d}\n", .{ key, offset.x, offset.y }); + } + return output.toOwnedSlice(); + } + + pub fn decodeNodeOffsets(self: *CanvasState, data: []const u8) !void { + var offsets = [_]NodeOffset{.{}} ** 512; + var keys = [_]u64{0} ** 512; + var used_entries = [_]bool{false} ** 512; + var next: usize = 0; + var lines = std.mem.splitScalar(u8, data, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + var fields = std.mem.splitScalar(u8, line, '\t'); + const key_text = fields.next() orelse return error.InvalidCanvasLayout; + const x_text = fields.next() orelse return error.InvalidCanvasLayout; + const y_text = fields.next() orelse return error.InvalidCanvasLayout; + if (fields.next() != null) return error.InvalidCanvasLayout; + const key = try std.fmt.parseInt(u64, key_text, 16); + const x = try std.fmt.parseFloat(f32, x_text); + const y = try std.fmt.parseFloat(f32, y_text); + if (!std.math.isFinite(x) or !std.math.isFinite(y) or + @abs(x) > 100_000 or @abs(y) > 100_000) + return error.InvalidCanvasLayout; + var existing: ?usize = null; + for (keys[0..next], used_entries[0..next], 0..) |stored_key, used, index| { + if (used and stored_key == key) { + existing = index; + break; + } + } + const index = existing orelse blk: { + if (next >= offsets.len) return error.CanvasLayoutTooLarge; + const result = next; + next += 1; + break :blk result; + }; + keys[index] = key; + used_entries[index] = true; + offsets[index] = .{ .x = x, .y = y }; + } + self.node_offsets = offsets; + self.node_offset_keys = keys; + self.node_offset_used = used_entries; + } + + pub fn endEdgeDrag(self: *CanvasState) ?[]const u8 { + if (!self.edge_dragging) return null; + const source_id = self.edge_drag_source_id; + self.edge_dragging = false; + self.edge_drag_source_id = ""; + return source_id; + } + + pub fn zoomAt(self: *CanvasState, x: i32, y: i32, wheel_delta: i16) void { + const factor: f32 = if (wheel_delta > 0) 1.1 else 0.9; + self.zoomBy(x, y, factor); + } + + pub fn zoomBy(self: *CanvasState, x: i32, y: i32, factor: f32) void { + const old_zoom = self.zoom; + const next = std.math.clamp(old_zoom * factor, 0.55, 1.8); + if (next == old_zoom) return; + const world_x = (@as(f32, @floatFromInt(x)) - self.pan_x) / old_zoom; + const world_y = (@as(f32, @floatFromInt(y)) - self.pan_y) / old_zoom; + self.zoom = next; + self.pan_x = @as(f32, @floatFromInt(x)) - world_x * next; + self.pan_y = @as(f32, @floatFromInt(y)) - world_y * next; + } + + pub fn actualSize(self: *CanvasState) void { + self.zoom = 1; + self.pan_x = 0; + self.pan_y = 0; + } + + pub fn fit(self: *CanvasState, bounds: c.RECT, content_width: i32, content_height: i32) void { + if (content_width <= 0 or content_height <= 0) return; + const viewport_width = @max(1, bounds.right - bounds.left - 48); + const viewport_height = @max(1, bounds.bottom - bounds.top - 48); + self.zoom = std.math.clamp(@min( + @as(f32, @floatFromInt(viewport_width)) / @as(f32, @floatFromInt(content_width)), + @as(f32, @floatFromInt(viewport_height)) / @as(f32, @floatFromInt(content_height)), + ), 0.55, 1.8); + self.pan_x = @as(f32, @floatFromInt(bounds.left + 24)) + + (@as(f32, @floatFromInt(viewport_width)) - @as(f32, @floatFromInt(content_width)) * self.zoom) / 2; + self.pan_y = @as(f32, @floatFromInt(bounds.top + 24)) + + (@as(f32, @floatFromInt(viewport_height)) - @as(f32, @floatFromInt(content_height)) * self.zoom) / 2; + } +}; + +fn nodeKey(id: []const u8) u64 { + return std.hash.Wyhash.hash(0, id); +} + +pub const CardTextLayout = struct { + title_y: i32, + state_y: i32, + show_entry: bool, + show_activity: bool, + show_attention: bool, +}; + +pub const RenderBounds = struct { left: i32, top: i32, right: i32, bottom: i32 }; +pub const Surface = enum { project, overview, quick_chats, workspace }; +pub const OverviewHit = struct { graph_index: usize, node_index: usize }; +pub const ZoomControl = enum { out, actual, in, fit }; +pub const HeaderAction = enum { review_attention, inspect_worktrees, jump, toggle_panel }; +pub const ReclaimAction = enum { reclaim, keep }; +pub const ReclaimHit = struct { node_index: usize, action: ReclaimAction }; + +pub fn renderBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControls.State) RenderBounds { + const left = if (controls.rail_visible) Tokens.sidebar_width else 0; + const activity = if (controls.activity_enabled) Tokens.activity_strip_height else 0; + return .{ + .left = left, + .top = Tokens.header_height, + .right = client_right, + .bottom = @max( + Tokens.header_height + 1, + client_bottom - (if (controls.panel_visible) Tokens.workspace_height else 0) - activity, + ), + }; +} + +pub fn paint( + hwnd: c.HWND, + hdc: c.HDC, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + selected_worktree_path: []const u8, + sidebar_scroll: i32, + status: []const u8, + update_version: []const u8, + ingress_error: []const u8, + connection_failed: bool, + declared_entries: []const []const u8, + kept_worktrees: []const []const u8, + allocator: std.mem.Allocator, + state: *const CanvasState, + sidebar_state: *const Sidebar.State, + sidebar_hover_y: i32, + controls: WorkspaceControls.State, + surface: Surface, +) void { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + fill(hdc, client, Tokens.canvas_tone); + const visible_inspection = if (inspection) |value| + if (model.graph) |graph| + if (std.mem.eql(u8, value.project_path, graph.project.path)) value else null + else + null + else + null; + header(hdc, allocator, client.right, status, model, inspection, surface); + if (controls.rail_visible) { + const sidebar_bottom = if (controls.panel_visible and surface != .workspace) + client.bottom - Tokens.workspace_height + else + client.bottom; + Sidebar.draw( + hdc, + model, + visible_inspection, + selected_worktree_path, + sidebar_scroll, + status, + sidebar_bottom, + update_version, + ingress_error, + sidebar_state, + sidebar_hover_y, + allocator, + ); + } + + var presentation_controls = controls; + if (surface == .workspace) presentation_controls.panel_visible = false; + const bounds = renderBounds(client.right, client.bottom, presentation_controls); + const graph_bounds = rect(bounds.left, bounds.top, bounds.right, bounds.bottom); + fill(hdc, graph_bounds, Tokens.canvas_tone); + const saved = c.SaveDC(hdc); + _ = c.IntersectClipRect(hdc, graph_bounds.left, graph_bounds.top, graph_bounds.right, graph_bounds.bottom); + drawGrid(hdc, graph_bounds, state); + switch (surface) { + .overview => drawOverview(hdc, allocator, model, graph_bounds, state), + .quick_chats => drawQuickChats(hdc, allocator, model, graph_bounds, state), + .project => if (model.graph) |graph| { + drawCompositeBreadcrumb(hdc, allocator, model, graph_bounds); + if (graph.nodes.items.len == 0) { + emptyGraph(hdc, allocator, graph, graph_bounds); + } else { + drawEdges(hdc, graph, state); + for (graph.nodes.items, 0..) |node, index| { + drawNode(hdc, allocator, node, index, model.selectedIndex(), graph.nodes.items, graph.edges.items, inspection, declared_entries, kept_worktrees, state); + } + + drawEdgeLabels(hdc, allocator, graph, state); + } + } else { + welcome(hdc, allocator, graph_bounds); + }, + .workspace => {}, + } + const alert_text = if (ingress_error.len != 0) + ingress_error + else if (connection_failed) + connection_failure_message + else + ""; + if (surface != .workspace and alert_text.len != 0) { + const alert = inlineAlertBounds(graph_bounds); + fill(hdc, alert, 0x0034242A); + drawTextRect( + hdc, + allocator, + alert_text, + rect(alert.left + 14, alert.top + 8, alert.right - 14, alert.bottom - 8), + 11, + 0x008080FF, + c.DT_LEFT | c.DT_VCENTER | c.DT_WORDBREAK, + ); + } + if (surface != .workspace) drawZoomControls(hdc, allocator, graph_bounds, state); + + _ = c.RestoreDC(hdc, saved); + attentionRail(hdc, allocator, model, client.right); + if (controls.activity_enabled) { + const workspace_height: i32 = if (surface == .workspace) 0 else if (controls.panel_visible) Tokens.workspace_height else 0; + activityStrip( + hdc, + allocator, + model, + rect(0, client.bottom - workspace_height - Tokens.activity_strip_height, + client.right, client.bottom - workspace_height), + ); + } +} + +pub fn inlineAlertBounds(bounds: c.RECT) c.RECT { + return rect( + bounds.left + 24, + @max(bounds.top + 24, bounds.bottom - 94), + bounds.right - 24, + bounds.bottom - 38, + ); +} + +fn drawCompositeBreadcrumb( + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, +) void { + const graph = model.graph orelse return; + const title = model.open_composite_title orelse return; + const label = std.fmt.allocPrint( + allocator, + "‹ {s} > {s} · {d} loop{s}", + .{ graph.project.name, title, graph.nodes.items.len, if (graph.nodes.items.len == 1) "" else "s" }, + ) catch return; + defer allocator.free(label); + const crumb = compositeBreadcrumbBounds(bounds); + fill(hdc, crumb, 0x00292825); + drawTextRect(hdc, allocator, label, crumb, 11, 0x00E6E6E6, c.DT_LEFT | c.DT_SINGLELINE | c.DT_VCENTER); +} + +pub fn compositeBreadcrumbBounds(bounds: c.RECT) c.RECT { + return rect(bounds.left + 18, bounds.top + 14, @min(bounds.right - 18, bounds.left + 520), bounds.top + 42); +} + +pub fn hitTestCompositeBack(model: *const GraphModel.Model, x: i32, y: i32, bounds: c.RECT) bool { + const crumb = compositeBreadcrumbBounds(bounds); + return model.isCompositeOpen() and + x >= crumb.left and x < crumb.right and y >= crumb.top and y < crumb.bottom; +} + +fn drawOverview( + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, + state: *const CanvasState, + ) void { + if (model.graphs.items.len == 0) { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + drawTextRect(hdc, allocator, "Nothing running yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, "Loops from every folder you open show up here, wired to how they run.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + return; + } + for (model.graphs.items, 0..) |graph, graph_index| { + const lane = overviewLaneBounds(model, graph_index, bounds, state); + roundedCard(hdc, lane, 0x001D1D21, false); + drawText(hdc, allocator, graph.project.name, lane.left + scaledValue(18, state.zoom), lane.top + scaledValue(16, state.zoom), scaledValue(14, state.zoom), 0x00E8E8E8); + var index: usize = 0; + while (index < graph.nodes.items.len) : (index += 1) { + const card = overviewCardBounds(model, graph_index, index, bounds, state); + roundedCard(hdc, card, 0x00262626, false); + fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), stateColor(graph.nodes.items[index].state, false)); + drawText(hdc, allocator, graph.nodes.items[index].title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(16, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); + drawText(hdc, allocator, graph.nodes.items[index].state, card.left + scaledValue(14, state.zoom), card.top + scaledValue(46, state.zoom), scaledValue(10, state.zoom), 0x00B8B8B8); + } + } + } + +fn drawQuickChats( + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, + state: *const CanvasState, + ) void { + if (model.quick_chats.items.len == 0) { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + drawTextRect(hdc, allocator, "No chats yet", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, "A quick chat is a bare session for questions that are not a loop's work.", rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); + return; + } + const rows = (model.quick_chats.items.len + 2) / 3; + const band = transformedRect(bounds, state, 24, 34, @max(760, bounds.right - bounds.left - 48), @as(i32, @intCast(rows * 104 + 32))); + roundedCard(hdc, band, 0x001D1D21, false); + for (model.quick_chats.items, 0..) |chat, index| { + const card = quickChatCardBounds(index, bounds, state); + roundedCard(hdc, card, 0x00262626, false); + fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), 0x007A7A7A); + drawText(hdc, allocator, chat.title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(12, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); + drawText(hdc, allocator, if (std.mem.eql(u8, chat.backend, "claudeCode")) "chat" else chat.backend, card.left + scaledValue(14, state.zoom), card.top + scaledValue(37, state.zoom), scaledValue(10, state.zoom), 0x009A9A9A); + } +} + +fn overviewLaneHeight(node_count: usize) i32 { + return @max(@as(i32, 96), @as(i32, @intCast(((node_count + 2) / 3) * 128 + 48))); +} + +fn overviewLaneBounds( + model: *const GraphModel.Model, + graph_index: usize, + bounds: c.RECT, + state: *const CanvasState, +) c.RECT { + var top: i32 = 38; + for (model.graphs.items[0..@min(graph_index, model.graphs.items.len)]) |graph| { + top += overviewLaneHeight(graph.nodes.items.len) + 20; + } + const height = if (graph_index < model.graphs.items.len) + overviewLaneHeight(model.graphs.items[graph_index].nodes.items.len) + else + 0; + return transformedRect(bounds, state, 24, top, @max(760, bounds.right - bounds.left - 48), height); +} + +pub fn overviewCardBounds( + model: *const GraphModel.Model, + graph_index: usize, + node_index: usize, + bounds: c.RECT, + state: *const CanvasState, +) c.RECT { + const lane = overviewLaneBounds(model, graph_index, bounds, state); + const column = @as(i32, @intCast(node_index % 3)); + const row = @as(i32, @intCast(node_index / 3)); + return rect( + lane.left + scaledValue(18 + column * 246, state.zoom), + lane.top + scaledValue(46 + row * 122, state.zoom), + lane.left + scaledValue(238 + column * 246, state.zoom), + lane.top + scaledValue(132 + row * 122, state.zoom), + ); +} + +pub fn quickChatCardBounds(index: usize, bounds: c.RECT, state: *const CanvasState) c.RECT { + const column = @as(i32, @intCast(index % 3)); + const row = @as(i32, @intCast(index / 3)); + return transformedRect(bounds, state, 42 + column * 246, 54 + row * 104, 220, 64); +} + +fn transformedRect(bounds: c.RECT, state: *const CanvasState, x: i32, y: i32, width: i32, height: i32) c.RECT { + const left = @as(i32, @intFromFloat(@as(f32, @floatFromInt(bounds.left + x)) * state.zoom + state.pan_x)); + const top = @as(i32, @intFromFloat(@as(f32, @floatFromInt(bounds.top + y)) * state.zoom + state.pan_y)); + return rect(left, top, left + scaledValue(width, state.zoom), top + scaledValue(height, state.zoom)); +} + +fn zoomControlsBounds(bounds: c.RECT) c.RECT { + return rect(bounds.right - 190, bounds.bottom - 48, bounds.right - 12, bounds.bottom - 12); +} + +fn zoomButtonBounds(bounds: c.RECT, index: i32) c.RECT { + const controls = zoomControlsBounds(bounds); + const widths = [_]i32{ 36, 68, 36, 38 }; + var left = controls.left; + var current: i32 = 0; + while (current < index) : (current += 1) left += widths[@intCast(current)]; + return rect(left, controls.top, left + widths[@intCast(index)], controls.bottom); +} + +fn drawZoomControls(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT, state: *const CanvasState) void { + roundedCard(hdc, zoomControlsBounds(bounds), 0x0026262A, false); + const labels = [_][]const u8{ "-", "", "+", "Fit" }; + for (labels, 0..) |label, index| { + const button = zoomButtonBounds(bounds, @intCast(index)); + if (index != 0) fill(hdc, rect(button.left, button.top + 7, button.left + 1, button.bottom - 7), 0x00454549); + if (index == 1) { + var percent: [16]u8 = undefined; + const text = std.fmt.bufPrint(&percent, "{d}%", .{@as(i32, @intFromFloat(state.zoom * 100))}) catch "100%"; + drawTextRect(hdc, allocator, text, button, 11, 0x00E0E0E0, c.DT_CENTER | c.DT_VCENTER | c.DT_SINGLELINE); + } else { + drawTextRect(hdc, allocator, label, button, 11, 0x00E0E0E0, c.DT_CENTER | c.DT_VCENTER | c.DT_SINGLELINE); + } + } +} + +pub fn hitTestZoomControl(x: i32, y: i32, bounds: c.RECT) ?ZoomControl { + if (!insideGraph(x, y, zoomControlsBounds(bounds))) return null; + var index: i32 = 0; + while (index < 4) : (index += 1) { + const button = zoomButtonBounds(bounds, index); + if (insideGraph(x, y, button)) return switch (index) { + 0 => .out, + 1 => .actual, + 2 => .in, + else => .fit, + }; + } + return null; +} + +pub fn contentSize(model: *const GraphModel.Model, surface: Surface) struct { width: i32, height: i32 } { + return switch (surface) { + .overview => blk: { + var height: i32 = 38; + for (model.graphs.items) |graph| height += overviewLaneHeight(graph.nodes.items.len) + 20; + break :blk .{ .width = 808, .height = @max(1, height) }; + }, + .quick_chats => .{ + .width = 808, + .height = @max(1, @as(i32, @intCast(((model.quick_chats.items.len + 2) / 3) * 104 + 66))), + }, + .project => if (model.graph) |graph| .{ + .width = @max(1, @as(i32, @intCast(@min(graph.nodes.items.len, 3))) * 260 + 64), + .height = @max(1, @as(i32, @intCast((graph.nodes.items.len + 2) / 3)) * 140 + 100), + } else .{ .width = 1, .height = 1 }, + .workspace => .{ .width = 1, .height = 1 }, + }; +} + +pub fn hitTestOverview( + model: *const GraphModel.Model, + x: i32, + y: i32, + state: *const CanvasState, + graph_bounds: c.RECT, +) ?OverviewHit { + if (!insideGraph(x, y, graph_bounds)) return null; + var graph_index = model.graphs.items.len; + while (graph_index > 0) { + graph_index -= 1; + const nodes = model.graphs.items[graph_index].nodes.items; + var node_index = nodes.len; + while (node_index > 0) { + node_index -= 1; + const bounds = overviewCardBounds(model, graph_index, node_index, graph_bounds, state); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) { + return .{ .graph_index = graph_index, .node_index = node_index }; + } + } + } + return null; +} + +pub fn hitTestQuickChat( + chat_count: usize, + x: i32, + y: i32, + state: *const CanvasState, + graph_bounds: c.RECT, +) ?usize { + if (!insideGraph(x, y, graph_bounds)) return null; + var index = chat_count; + while (index > 0) { + index -= 1; + const bounds = quickChatCardBounds(index, graph_bounds, state); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) return index; + } + return null; +} + +fn welcome(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT) void { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 70; + drawTextRect(hdc, allocator, "◇", rect(bounds.left, center_y - 54, bounds.right, center_y - 10), 34, 0x008A8A8A, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, "Create a graph of loops for a folder", rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 22, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect( + hdc, + allocator, + "Open a folder or git repository to start orchestrating a graph of AI coding loops in it.", + rect(bounds.left + 100, center_y + 42, bounds.right - 100, center_y + 92), + 13, + 0x00A8A8AE, + c.DT_CENTER | c.DT_WORDBREAK, + ); +} + +fn emptyGraph(hdc: c.HDC, allocator: std.mem.Allocator, graph: GraphModel.Graph, bounds: c.RECT) void { + const center_y = bounds.top + @divTrunc(bounds.bottom - bounds.top, 2) - 60; + const title = if (graph.project.isGlobal()) "Nothing running yet" else "No loops yet"; + const message = if (graph.project.isGlobal()) + "Loops from every folder you open show up here, wired to how they run." + else + "Create the first loop in this folder to start its graph."; + drawTextRect(hdc, allocator, title, rect(bounds.left + 40, center_y, bounds.right - 40, center_y + 34), 20, 0x00F2F2F2, c.DT_CENTER | c.DT_SINGLELINE); + drawTextRect(hdc, allocator, message, rect(bounds.left + 100, center_y + 40, bounds.right - 100, center_y + 86), 13, 0x00A8A8AE, c.DT_CENTER | c.DT_WORDBREAK); +} + +test "workspace controls change graph render bounds" { + const shown = renderBounds(1200, 900, .{}); + const hidden = renderBounds(1200, 900, .{ .rail_visible = false, .activity_enabled = false }); + try std.testing.expectEqual(@as(i32, Tokens.sidebar_width), shown.left); + try std.testing.expectEqual(@as(i32, 0), hidden.left); + try std.testing.expect(hidden.bottom > shown.bottom); +} + +test "inline canvas alerts remain inside the active detail surface" { + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800); + const alert = inlineAlertBounds(bounds); + try std.testing.expect(alert.left > bounds.left); + try std.testing.expect(alert.top > bounds.top); + try std.testing.expect(alert.right < bounds.right); + try std.testing.expect(alert.bottom < bounds.bottom); +} + +fn header( + hdc: c.HDC, + allocator: std.mem.Allocator, + width: i32, + status: []const u8, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + surface: Surface, +) void { + fill(hdc, rect(0, 0, width, Tokens.header_height), Tokens.window_tone); + if (surface == .workspace and model.currentGraph() != null) { + const project = model.currentGraph().?.project; + drawText(hdc, allocator, project.name, 16, 7, 15, 0x00FFFFFF); + drawText(hdc, allocator, if (project.isRemote()) "Remote" else "Local folder", 172, 10, 11, 0x008E8E93); + } else { + drawText(hdc, allocator, "GraphCode Windows", 16, 8, 15, 0x00FFFFFF); + } + if (model.attentionCount() != 0) { + const bounds = headerAttentionRect(); + fill(hdc, bounds, 0x00352B1C); + var buffer: [48]u8 = undefined; + const label = std.fmt.bufPrint(&buffer, "{d} need you", .{model.attentionCount()}) catch "Needs you"; + drawTextRect(hdc, allocator, label, bounds, 10, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + } + if (inspection) |value| { + const summary = WorktreeStatus.summarize(value.entries.items); + const bounds = headerWorktreeRect(); + fill(hdc, bounds, 0x002D2418); + var buffer: [64]u8 = undefined; + const label = if (summary.reclaimable != 0) + std.fmt.bufPrint(&buffer, "{d} reclaimable", .{summary.reclaimable}) catch "Worktrees" + else + std.fmt.bufPrint(&buffer, "{d} worktrees", .{summary.total}) catch "Worktrees"; + drawTextRect(hdc, allocator, label, bounds, 10, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + } + const jump = headerJumpRect(width); + fill(hdc, jump, 0x00282828); + drawTextRect(hdc, allocator, "Jump to loop Ctrl+P", jump, 10, 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + if (model.currentGraph() != null) { + const panel = headerPanelRect(width); + fill(hdc, panel, 0x00282828); + drawTextRect(hdc, allocator, if (surface == .workspace) "Hide loop panel" else "Loop panel", panel, 10, 0x00D8D8D8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + } + drawText(hdc, allocator, status, width - 270, 9, 11, 0x00A8A8A8); +} + +pub fn headerAttentionRect() c.RECT { + return rect(220, 5, 330, Tokens.header_height - 5); +} + +pub fn headerWorktreeRect() c.RECT { + return rect(338, 5, 458, Tokens.header_height - 5); +} + +pub fn headerJumpRect(width: i32) c.RECT { + return rect(width - 560, 5, width - 400, Tokens.header_height - 5); +} + +pub fn headerPanelRect(width: i32) c.RECT { + return rect(width - 390, 5, width - 280, Tokens.header_height - 5); +} + +pub fn headerActionAt( + x: i32, + y: i32, + width: i32, + has_attention: bool, + has_worktrees: bool, + has_graph: bool, +) ?HeaderAction { + if (y < 0 or y >= Tokens.header_height) return null; + if (has_attention and insideGraph(x, y, headerAttentionRect())) return .review_attention; + if (has_worktrees and insideGraph(x, y, headerWorktreeRect())) return .inspect_worktrees; + if (insideGraph(x, y, headerJumpRect(width))) return .jump; + if (has_graph and insideGraph(x, y, headerPanelRect(width))) return .toggle_panel; + return null; +} + +fn attentionRail( + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + width: i32, +) void { + if (model.attentionCount() == 0) return; + var count: [32]u8 = undefined; + const label = if (model.attentionCount() == 1) + "1 loop needs you" + else + std.fmt.bufPrint(&count, "{d} loops need you", .{model.attentionCount()}) catch "loops need you"; + fill(hdc, rect(Tokens.sidebar_width + 20, Tokens.header_height + 12, width - 20, Tokens.header_height + 43), 0x002D2418); + drawText(hdc, allocator, label, Tokens.sidebar_width + 34, Tokens.header_height + 21, 12, 0x00FFCD7A); + drawText(hdc, allocator, "Ctrl+Tab review", Tokens.sidebar_width + 210, Tokens.header_height + 21, 11, 0x00B8B8B8); +} + +fn activityStrip( + hdc: c.HDC, + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + bounds: c.RECT, +) void { + fill(hdc, bounds, 0x001D1D21); + drawText(hdc, allocator, "ACTIVITY", bounds.left + 16, bounds.top + 10, 10, 0x007A7A7A); + var count_buffer: [32]u8 = undefined; + const summary = std.fmt.bufPrint(&count_buffer, "{d} recent", .{model.activity.items.len}) catch "recent"; + drawText(hdc, allocator, summary, bounds.left + 76, bounds.top + 10, 10, 0x00909098); + var x = bounds.left + 142; + const visible_count = @min(model.activity.items.len, 4); + const start = model.activity.items.len - visible_count; + for (model.activity.items[start..]) |event| { + const card = rect(x, bounds.top + 5, @min(x + 176, bounds.right - 8), bounds.bottom - 5); + if (card.right <= card.left) break; + roundedCard(hdc, card, 0x0026262B, false); + fill(hdc, rect(card.left, card.top, card.left + 3, card.bottom), stateColor(event.state, false)); + drawText(hdc, allocator, event.title, card.left + 10, card.top + 7, 10, 0x00D8D8DE); + drawText(hdc, allocator, compactActivityState(event.state), card.left + 10, card.top + 22, 9, stateColor(event.state, false)); + x += 184; + if (x >= bounds.right - 80) break; + } +} + +fn compactActivityState(state: []const u8) []const u8 { + if (std.mem.eql(u8, state, "succeeded")) return "completed"; + if (std.mem.eql(u8, state, "awaitingInput")) return "needs attention"; + return state; +} + +fn drawGrid(hdc: c.HDC, bounds: c.RECT, state: *const CanvasState) void { + const pen = c.CreatePen(c.PS_SOLID, 1, Tokens.rgb(Tokens.canvas_grid_line)); + if (pen == null) return; + const old = c.SelectObject(hdc, pen); + const cell = @max(8, @as(i32, @intFromFloat(@as(f32, Tokens.canvas_grid_cell) * state.zoom))); + var x = bounds.left + @mod(@as(i32, @intFromFloat(state.pan_x)), cell); + while (x < bounds.right) : (x += cell) { + _ = c.MoveToEx(hdc, x, bounds.top, null); + _ = c.LineTo(hdc, x, bounds.bottom); + } + var y = bounds.top + @mod(@as(i32, @intFromFloat(state.pan_y)), cell); + while (y < bounds.bottom) : (y += cell) { + _ = c.MoveToEx(hdc, bounds.left, y, null); + _ = c.LineTo(hdc, bounds.right, y); + } + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(pen); +} + +fn drawEdges(hdc: c.HDC, graph: GraphModel.Graph, state: *const CanvasState) void { + for (graph.edges.items, 0..) |edge, index| { + const from = connectorPosition(graph.nodes.items, edge.from, true, state) orelse continue; + const to = connectorPosition(graph.nodes.items, edge.to, false, state) orelse continue; + const selected = if (state.selected_edge_id.len != 0) + std.mem.eql(u8, edge.id, state.selected_edge_id) + else + state.selected_edge == index; + const color = if (selected) + Tokens.rgb(Tokens.canvas_selection) + else if (edge.fired or edge.fire_count != 0) + 0x006BD58D + else + edgeKindColor(edge.kind); + drawBezier(hdc, from, to, color, edgeKindPenStyle(edge.kind)); + } + if (state.edge_dragging) { + if (state.edge_drag_source_id.len != 0) { + if (connectorPosition(graph.nodes.items, state.edge_drag_source_id, true, state)) |from| { + drawBezier(hdc, from, .{ .x = state.edge_drag_x, .y = state.edge_drag_y }, Tokens.rgb(Tokens.canvas_selection), c.PS_SOLID); + } + } + } +} + +fn drawEdgeLabels(hdc: c.HDC, allocator: std.mem.Allocator, graph: GraphModel.Graph, state: *const CanvasState) void { + for (graph.edges.items, 0..) |edge, index| { + const from = connectorPosition(graph.nodes.items, edge.from, true, state) orelse continue; + const to = connectorPosition(graph.nodes.items, edge.to, false, state) orelse continue; + const selected = if (state.selected_edge_id.len != 0) + std.mem.eql(u8, edge.id, state.selected_edge_id) + else + state.selected_edge == index; + const color = if (selected) + Tokens.rgb(Tokens.canvas_selection) + else if (edge.fired or edge.fire_count != 0) + 0x006BD58D + else + edgeKindColor(edge.kind); + var label_buffer: [128]u8 = undefined; + const label = edgeLabel(&label_buffer, edge); + const center_x = @divTrunc(from.x + to.x, 2); + const label_y = if (@abs(to.y - from.y) < 40) + @min(from.y, to.y) - 76 + else + @divTrunc(from.y + to.y, 2) - 10; + const bounds = rect(center_x - 74, label_y, center_x + 74, label_y + 20); + fill(hdc, bounds, Tokens.canvas_tone); + drawTextRect(hdc, allocator, label, bounds, 10, color, c.DT_CENTER | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); + } +} + +fn edgeKindPenStyle(kind: []const u8) c_int { + if (std.mem.eql(u8, kind, "message")) return c.PS_DOT; + if (std.mem.eql(u8, kind, "spawn")) return c.PS_DASH; + return c.PS_SOLID; +} + +fn edgeKindColor(kind: []const u8) u32 { + if (std.mem.eql(u8, kind, "message")) return 0x00D6A649; + if (std.mem.eql(u8, kind, "spawn")) return 0x00C77DFF; + return Tokens.rgb(Tokens.canvas_edge); +} + +fn edgeLabel(buffer: []u8, edge: GraphModel.Edge) []const u8 { + const kind = if (edge.kind.len == 0) "handoff" else edge.kind; + if (edge.fire_count != 0) + return std.fmt.bufPrint(buffer, "{s} · {s} · fired {d}", .{ kind, edge.condition, edge.fire_count }) catch kind; + if (!std.mem.eql(u8, edge.condition, "always")) + return std.fmt.bufPrint(buffer, "{s} · {s}", .{ kind, edge.condition }) catch kind; + return kind; +} + +fn drawBezier(hdc: c.HDC, from: Connector, to: Connector, color: u32, style: c_int) void { + const pen = c.CreatePen(style, if (style == c.PS_SOLID) 2 else 1, color); + if (pen == null) return; + const old = c.SelectObject(hdc, pen); + const distance: i32 = if (to.x >= from.x) to.x - from.x else from.x - to.x; + const bend: i32 = @max(@as(i32, 24), @divTrunc(distance, 2)); + var points = [_]c.POINT{ + .{ .x = from.x, .y = from.y }, + .{ .x = from.x + bend, .y = from.y }, + .{ .x = to.x - bend, .y = to.y }, + .{ .x = to.x, .y = to.y }, + }; + _ = c.PolyBezier(hdc, &points, 4); + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(pen); +} + +const Connector = struct { + x: i32, + y: i32, +}; + +fn connectorPosition(nodes: []const GraphModel.Node, node_id: []const u8, outgoing: bool, state: *const CanvasState) ?Connector { + for (nodes, 0..) |node, index| { + if (!std.mem.eql(u8, node.id, node_id)) continue; + return connectorPositionForIndex(nodes, index, outgoing, state); + } + return null; +} + +fn connectorPositionForIndex(nodes: []const GraphModel.Node, index: usize, outgoing: bool, state: *const CanvasState) Connector { + _ = nodes; + const bounds = nodeBounds(index, state); + return .{ + .x = if (outgoing) bounds.right else bounds.left, + .y = @divTrunc(bounds.top + bounds.bottom, 2), + }; +} + +fn drawNode( + hdc: c.HDC, + allocator: std.mem.Allocator, + node: GraphModel.Node, + index: usize, + selected: ?usize, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + inspection: ?*const WorktreeStatus.Inspection, + declared_entries: []const []const u8, + kept_worktrees: []const []const u8, + state: *const CanvasState, +) void { + const bounds = nodeBounds(index, state); + const x = bounds.left; + const y = bounds.top; + const attention = needsAttention(node, nodes, edges); + const selected_card = selected == index; + roundedCard(hdc, bounds, if (selected_card) 0x00345D8C else 0x00262626, selected_card); + const stripe = stateColor(node.state, attention); + fill(hdc, rect(x, y, x + scaled(Tokens.loop_card_stripe, state), y + bounds.bottom - y), stripe); + const role = nodeRole(edges, node.id, declared_entries); + const reclaim_offer = hasReclaimOffer(node, inspection, kept_worktrees); + const layout = cardTextLayout(state.zoom, role == .entry, attention); + if (layout.show_entry) drawText(hdc, allocator, "START", x + scaled(14, state), y + layout.title_y - scaled(10, state), scaled(9, state), 0x008A8A8A); + if (role == .unwired) drawText(hdc, allocator, "UNWIRED", x + scaled(14, state), y + layout.title_y - scaled(10, state), scaled(9, state), 0x00FFCD7A); + drawText(hdc, allocator, node.title, x + scaled(14, state), y + layout.title_y, scaled(14, state), 0x00FFFFFF); + drawText(hdc, allocator, node.state, x + scaled(14, state), y + layout.state_y, scaled(11, state), if (attention) 0x00FFB340 else 0x00B8B8B8); + if (layout.show_activity) { + const primary = nodePrimaryDetail(node); + if (primary.len != 0) + drawText(hdc, allocator, primary, x + scaled(14, state), y + layout.state_y + scaled(20, state), scaled(9, state), 0x00A8A8A8); + if (node.activity.len != 0 and !std.mem.eql(u8, node.activity, primary)) + drawText(hdc, allocator, node.activity, x + scaled(14, state), y + layout.state_y + scaled(35, state), scaled(9, state), 0x008A8A8A); + var metadata_buffer: [128]u8 = undefined; + const metadata = nodeMetadata(&metadata_buffer, node); + if (metadata.len != 0 and (role != .unwired or reclaim_offer)) + drawText(hdc, allocator, metadata, x + scaled(14, state), y + layout.state_y + scaled(43, state), scaled(8, state), 0x007A7A7A); + if (role == .unwired and !reclaim_offer) + drawText(hdc, allocator, "No connections · right-click to recover", x + scaled(14, state), y + layout.state_y + scaled(43, state), scaled(8, state), 0x00FFCD7A); + } + if (layout.show_attention) drawText(hdc, allocator, "NEEDS YOU", bounds.right - scaled(88, state), y + scaled(8, state), scaled(9, state), 0x00FFB340); + if (reclaim_offer) { + const offer = reclaimOfferBounds(bounds); + fill(hdc, offer.reclaim, 0x003A3A44); + drawTextRect(hdc, allocator, "Reclaim", offer.reclaim, scaled(9, state), 0x00E6E6E6, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + drawTextRect(hdc, allocator, "Keep", offer.keep, scaled(9, state), 0x008A8A8A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + } +} + +const ReclaimOfferBounds = struct { reclaim: c.RECT, keep: c.RECT }; + +fn reclaimOfferBounds(bounds: c.RECT) ReclaimOfferBounds { + return .{ + .reclaim = rect(bounds.left + 12, bounds.bottom - 24, bounds.left + 78, bounds.bottom - 5), + .keep = rect(bounds.left + 84, bounds.bottom - 24, bounds.left + 126, bounds.bottom - 5), + }; +} + +fn hasReclaimOffer( + node: GraphModel.Node, + inspection: ?*const WorktreeStatus.Inspection, + kept_worktrees: []const []const u8, +) bool { + if (!std.mem.eql(u8, node.state, "succeeded") or node.worktree_path.len == 0) return false; + for (kept_worktrees) |path| if (std.mem.eql(u8, path, node.worktree_path)) return false; + const value = inspection orelse return false; + for (value.entries.items) |entry| { + if (std.mem.eql(u8, entry.path, node.worktree_path)) + return WorktreeStatus.decision(entry) == .reclaimable; + } + return false; +} + +pub fn hitTestReclaimOffer( + nodes: []const GraphModel.Node, + inspection: ?*const WorktreeStatus.Inspection, + kept_worktrees: []const []const u8, + x: i32, + y: i32, + state: *const CanvasState, +) ?ReclaimHit { + var index = nodes.len; + while (index > 0) { + index -= 1; + if (!hasReclaimOffer(nodes[index], inspection, kept_worktrees)) continue; + const offer = reclaimOfferBounds(nodeBounds(index, state)); + if (insideGraph(x, y, offer.reclaim)) return .{ .node_index = index, .action = .reclaim }; + if (insideGraph(x, y, offer.keep)) return .{ .node_index = index, .action = .keep }; + } + return null; +} + +fn nodePrimaryDetail(node: GraphModel.Node) []const u8 { + if (node.goal_summary.len != 0) return node.goal_summary; + if (node.trigger_prompt.len != 0) return node.trigger_prompt; + if (node.check_description.len != 0) return node.check_description; + return node.activity; +} + +fn nodeMetadata(buffer: []u8, node: GraphModel.Node) []const u8 { + if (node.worktree_branch.len != 0 and node.model_tier.len != 0) + return std.fmt.bufPrint(buffer, "{s} · {s}", .{ node.worktree_branch, node.model_tier }) catch node.worktree_branch; + if (node.worktree_branch.len != 0) return node.worktree_branch; + if (node.model_tier.len != 0) return node.model_tier; + if (node.metric_command.len != 0) + return std.fmt.bufPrint(buffer, "metric · {s}", .{if (node.metric_direction.len != 0) node.metric_direction else "configured"}) catch "metric"; + return ""; +} + +pub fn nodeBounds(index: usize, state: *const CanvasState) c.RECT { + const column = @as(i32, @intCast(index % 3)); + const row = @as(i32, @intCast(index / 3)); + const offset = if (index < state.node_offsets.len) state.node_offsets[index] else CanvasState.NodeOffset{}; + const x = @as(i32, @intFromFloat(((@as(f32, @floatFromInt(Tokens.sidebar_width + 32 + column * 260)) + offset.x) * state.zoom) + state.pan_x)); + const y = @as(i32, @intFromFloat(((@as(f32, @floatFromInt(Tokens.header_height + 50 + row * 140)) + offset.y) * state.zoom) + state.pan_y)); + return rect(x, y, x + scaled(Tokens.loop_card_width, state), y + scaled(Tokens.loop_card_height, state)); +} + +fn scaled(value: i32, state: *const CanvasState) i32 { + return @max(1, @as(i32, @intFromFloat(@as(f32, @floatFromInt(value)) * state.zoom))); +} + +fn stateColor(state: []const u8, attention: bool) u32 { + if (attention) return 0x00FF9F0A; + if (std.mem.eql(u8, state, "running")) return 0x000A84FF; + if (std.mem.eql(u8, state, "failed")) return 0x00FF453A; + if (std.mem.eql(u8, state, "blocked")) return 0x00FF9F0A; + if (std.mem.eql(u8, state, "succeeded")) return 0x0030D158; + return 0x00909090; +} + +fn needsAttention(node: GraphModel.Node, nodes: []const GraphModel.Node, edges: []const GraphModel.Edge) bool { + if (std.mem.eql(u8, node.state, "failed") or std.mem.eql(u8, node.state, "stalled")) return true; + if (std.mem.eql(u8, node.state, "running") and std.mem.eql(u8, node.presence, "awaitingInput")) return true; + if (!std.mem.eql(u8, node.state, "blocked")) return false; + for (edges) |edge| { + if (!std.mem.eql(u8, edge.to, node.id) or !std.mem.eql(u8, edge.kind, "handoff") or edge.fired) continue; + var source_found = false; + for (nodes) |source| { + if (std.mem.eql(u8, source.id, edge.from)) { + source_found = true; + if (!(std.mem.eql(u8, source.state, "failed") or std.mem.eql(u8, source.state, "stalled") or + std.mem.eql(u8, source.state, "succeeded") or std.mem.eql(u8, source.state, "stopped"))) + { + return false; + } + } + } + if (!source_found) return false; + // Continue checking every unfired handoff input; all must be resolved. + } + for (edges) |edge| { + if (std.mem.eql(u8, edge.to, node.id) and std.mem.eql(u8, edge.kind, "handoff") and !edge.fired) return true; + } + return false; +} + +const NodeRole = enum { interior, entry, unwired }; + +fn nodeRole(edges: []const GraphModel.Edge, node_id: []const u8, declared_entries: []const []const u8) NodeRole { + var inbound = false; + var outbound = false; + for (edges) |edge| { + if (std.mem.eql(u8, edge.to, node_id)) inbound = true; + if (std.mem.eql(u8, edge.from, node_id)) outbound = true; + } + if (inbound) return .interior; + if (outbound) return .entry; + for (declared_entries) |id| if (std.mem.eql(u8, id, node_id)) return .entry; + return .unwired; +} + +pub fn hitTest(nodes: []const GraphModel.Node, x: i32, y: i32, state: *const CanvasState, graph_bounds: c.RECT) ?usize { + if (x < graph_bounds.left or x >= graph_bounds.right or y < graph_bounds.top or y >= graph_bounds.bottom) return null; + var index = nodes.len; + while (index > 0) { + index -= 1; + const bounds = nodeBounds(index, state); + if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) return index; + } + + return null; +} + +pub fn hitTestConnector(nodes: []const GraphModel.Node, x: i32, y: i32, state: *const CanvasState, graph_bounds: c.RECT) ?usize { + if (!insideGraph(x, y, graph_bounds)) return null; + for (nodes, 0..) |_, index| { + const connector = connectorPositionForIndex(nodes, index, true, state); + if (distanceSquared(x, y, connector.x, connector.y) <= connectorRadius(state) * connectorRadius(state)) return index; + } + return null; +} + +pub fn hitTestEdge( + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + x: i32, + y: i32, + state: *const CanvasState, + graph_bounds: c.RECT, +) ?usize { + if (!insideGraph(x, y, graph_bounds)) return null; + for (edges, 0..) |edge, index| { + const from = connectorPosition(nodes, edge.from, true, state) orelse continue; + const to = connectorPosition(nodes, edge.to, false, state) orelse continue; + if (bezierDistanceSquared(from, to, x, y) <= edgeHitRadius(state) * edgeHitRadius(state)) return index; + } + return null; +} + +fn insideGraph(x: i32, y: i32, bounds: c.RECT) bool { + return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; +} + +fn connectorRadius(state: *const CanvasState) i32 { + return @max(7, @as(i32, @intFromFloat(9 * state.zoom))); +} + +fn edgeHitRadius(state: *const CanvasState) i32 { + return @max(6, @as(i32, @intFromFloat(8 * state.zoom))); +} + +fn distanceSquared(x1: i32, y1: i32, x2: i32, y2: i32) i32 { + const dx = x1 - x2; + const dy = y1 - y2; + return dx * dx + dy * dy; +} + +fn bezierDistanceSquared(from: Connector, to: Connector, x: i32, y: i32) i32 { + const distance: i32 = if (to.x >= from.x) to.x - from.x else from.x - to.x; + const bend: i32 = @max(@as(i32, 24), @divTrunc(distance, 2)); + var previous = from; + var best: i32 = std.math.maxInt(i32); + var step: i32 = 1; + while (step <= 24) : (step += 1) { + const t = @as(f32, @floatFromInt(step)) / 24.0; + const one = 1.0 - t; + const px = @as(f32, @floatFromInt(from.x)) * one * one * one + + @as(f32, @floatFromInt(from.x + bend)) * 3 * one * one * t + + @as(f32, @floatFromInt(to.x - bend)) * 3 * one * t * t + + @as(f32, @floatFromInt(to.x)) * t * t * t; + const py = @as(f32, @floatFromInt(from.y)) * one * one * one + + @as(f32, @floatFromInt(from.y)) * 3 * one * one * t + + @as(f32, @floatFromInt(to.y)) * 3 * one * t * t + + @as(f32, @floatFromInt(to.y)) * t * t * t; + const current = Connector{ .x = @intFromFloat(px), .y = @intFromFloat(py) }; + best = @min(best, segmentDistanceSquared(previous, current, x, y)); + previous = current; + } + return best; +} + +fn segmentDistanceSquared(a: Connector, b: Connector, x: i32, y: i32) i32 { + const ax = @as(f32, @floatFromInt(a.x)); + const ay = @as(f32, @floatFromInt(a.y)); + const bx = @as(f32, @floatFromInt(b.x)); + const by = @as(f32, @floatFromInt(b.y)); + const dx = bx - ax; + const dy = by - ay; + const denominator = dx * dx + dy * dy; + const raw = if (denominator == 0) 0 else ((@as(f32, @floatFromInt(x)) - ax) * dx + + (@as(f32, @floatFromInt(y)) - ay) * dy) / denominator; + const t = std.math.clamp(raw, 0, 1); + const px = ax + dx * t; + const py = ay + dy * t; + const ex = @as(f32, @floatFromInt(x)) - px; + const ey = @as(f32, @floatFromInt(y)) - py; + return @intFromFloat(ex * ex + ey * ey); +} + +fn cardTextLayout(zoom: f32, entry: bool, attention: bool) CardTextLayout { + if (zoom < 0.75) return .{ + .title_y = scaledValue(14, zoom), + .state_y = scaledValue(40, zoom), + .show_entry = false, + .show_activity = false, + .show_attention = false, + }; + return .{ + .title_y = scaledValue(if (entry) 20 else 14, zoom), + .state_y = scaledValue(if (entry) 47 else 43, zoom), + .show_entry = entry, + .show_activity = true, + .show_attention = attention, + }; +} + +fn scaledValue(value: i32, zoom: f32) i32 { + return @max(1, @as(i32, @intFromFloat(@as(f32, @floatFromInt(value)) * zoom))); +} + +pub fn paintLoopDetailRail( + hdc: c.HDC, + allocator: std.mem.Allocator, + graph: *const GraphModel.GraphSummary, + selected_index: usize, + client_right: i32, + client_bottom: i32, +) void { + if (selected_index >= graph.nodes.items.len) return; + const left = @max(0, client_right - Tokens.loop_detail_width); + const top = Tokens.header_height; + const node = graph.nodes.items[selected_index]; + fill(hdc, rect(left, top, client_right, client_bottom), 0x0028282C); + fill(hdc, rect(left, top, left + 1, client_bottom), 0x0045454B); + drawText(hdc, allocator, "LOOP MAP", left + 18, top + 16, 11, 0x009898A0); + + const map_top = top + 44; + roundedCard(hdc, rect(left + 18, map_top, client_right - 18, map_top + 72), 0x00303035, false); + const center_x = left + @divTrunc(Tokens.loop_detail_width, 2); + const center_y = map_top + 36; + for (graph.edges.items) |edge| { + const upstream = std.mem.eql(u8, edge.to, node.id); + const downstream = std.mem.eql(u8, edge.from, node.id); + if (!upstream and !downstream) continue; + const other_x = if (upstream) center_x - 72 else center_x + 72; + const pen = c.CreatePen(c.PS_SOLID, 2, if (edge.fired) 0x0058C878 else 0x00606068); + if (pen != null) { + const old = c.SelectObject(hdc, pen); + _ = c.MoveToEx(hdc, if (upstream) other_x + 8 else center_x + 8, center_y, null); + _ = c.LineTo(hdc, if (upstream) center_x - 8 else other_x - 8, center_y); + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(pen); + } + drawDot(hdc, other_x, center_y, if (edge.fired) 0x0058C878 else 0x00606068, 6); + } + drawDot(hdc, center_x, center_y, 0x00FFAE5A, 8); + + var y = map_top + 92; + drawText(hdc, allocator, "UPSTREAM", left + 18, y, 11, 0x009898A0); + y += 24; + var upstream_count: usize = 0; + for (graph.edges.items) |edge| { + if (!std.mem.eql(u8, edge.to, node.id)) continue; + upstream_count += 1; + paintRelationRow(hdc, allocator, graph, edge.from, edge.condition, edge.fired, left, y); + y += 42; + if (upstream_count == 3) break; + } + if (upstream_count == 0) { + drawText(hdc, allocator, "No incoming loops", left + 28, y, 12, 0x007A7A82); + y += 34; + } + + y += 8; + drawText(hdc, allocator, "DOWNSTREAM", left + 18, y, 11, 0x009898A0); + y += 24; + var downstream_count: usize = 0; + for (graph.edges.items) |edge| { + if (!std.mem.eql(u8, edge.from, node.id)) continue; + downstream_count += 1; + paintRelationRow(hdc, allocator, graph, edge.to, edge.condition, edge.fired, left, y); + y += 42; + if (downstream_count == 3) break; + } + if (downstream_count == 0) { + drawText(hdc, allocator, "No outgoing loops", left + 28, y, 12, 0x007A7A82); + y += 34; + } + + const footer_top = @max(y + 18, client_bottom - 148); + fill(hdc, rect(left + 18, footer_top, client_right - 18, footer_top + 1), 0x0045454B); + drawText(hdc, allocator, "DETAIL", left + 18, footer_top + 14, 11, 0x009898A0); + const branch = if (node.worktree_branch.len != 0) node.worktree_branch else if (node.worktree_path.len != 0) node.worktree_path else "Primary checkout"; + drawText(hdc, allocator, branch, left + 18, footer_top + 38, 12, 0x00D8D8DE); + const metric = if (node.metric_command.len != 0) node.metric_command else if (node.goal_summary.len != 0) node.goal_summary else "No metric configured"; + drawText(hdc, allocator, metric, left + 18, footer_top + 62, 12, 0x009898A0); + if (node.model_tier.len != 0) drawText(hdc, allocator, node.model_tier, left + 18, footer_top + 86, 12, 0x007AB8FF); +} + +fn paintRelationRow( + hdc: c.HDC, + allocator: std.mem.Allocator, + graph: *const GraphModel.GraphSummary, + node_id: []const u8, + condition: []const u8, + fired: bool, + left: i32, + y: i32, +) void { + roundedCard(hdc, rect(left + 18, y, left + Tokens.loop_detail_width - 18, y + 34), 0x00303035, false); + drawDot(hdc, left + 31, y + 17, if (fired) 0x0058C878 else 0x00686870, 4); + drawText(hdc, allocator, nodeTitle(graph, node_id), left + 44, y + 7, 12, 0x00E0E0E5); + if (!std.mem.eql(u8, condition, "always")) + drawText(hdc, allocator, condition, left + Tokens.loop_detail_width - 92, y + 7, 10, 0x009898A0); +} + +fn nodeTitle(graph: *const GraphModel.GraphSummary, id: []const u8) []const u8 { + for (graph.nodes.items) |node| if (std.mem.eql(u8, node.id, id)) return node.title; + return "Unknown loop"; +} + +fn drawDot(hdc: c.HDC, x: i32, y: i32, color: u32, radius: i32) void { + const brush = c.CreateSolidBrush(color); + if (brush == null) return; + const old = c.SelectObject(hdc, brush); + _ = c.Ellipse(hdc, x - radius, y - radius, x + radius, y + radius); + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(brush); +} + +fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { + return .{ .left = left, .top = top, .right = right, .bottom = bottom }; +} + +fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush != null) { + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); + } +} + +fn roundedCard(hdc: c.HDC, bounds: c.RECT, color: u32, selected: bool) void { + const brush = c.CreateSolidBrush(color); + const pen = c.CreatePen(c.PS_SOLID, if (selected) 2 else 1, if (selected) 0x007AB8FF else 0x00383838); + if (brush == null or pen == null) { + if (brush != null) _ = c.DeleteObject(brush); + if (pen != null) _ = c.DeleteObject(pen); + fill(hdc, bounds, color); + return; + } + const old_brush = c.SelectObject(hdc, brush); + const old_pen = c.SelectObject(hdc, pen); + _ = c.RoundRect(hdc, bounds.left, bounds.top, bounds.right, bounds.bottom, 12, 12); + _ = c.SelectObject(hdc, old_pen); + _ = c.SelectObject(hdc, old_brush); + _ = c.DeleteObject(pen); + _ = c.DeleteObject(brush); +} + +fn drawText( + hdc: c.HDC, + allocator: std.mem.Allocator, + text: []const u8, + x: i32, + y: i32, + size: i32, + color: u32, +) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; + defer allocator.free(wide); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = rect(x, y, 1200, y + size + 8); + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); +} + +fn drawTextRect( + hdc: c.HDC, + allocator: std.mem.Allocator, + text_value: []const u8, + bounds_value: c.RECT, + size: i32, + color: u32, + format: c.UINT, +) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text_value) catch return; + defer allocator.free(wide); + const font = c.CreateFontW( + -size, 0, 0, 0, c.FW_NORMAL, 0, 0, 0, c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, c.CLIP_DEFAULT_PRECIS, c.CLEARTYPE_QUALITY, + c.DEFAULT_PITCH | c.FF_DONTCARE, + std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, + ); + const old_font = if (font != null) c.SelectObject(hdc, font) else null; + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = bounds_value; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); + if (font != null) { + _ = c.SelectObject(hdc, old_font); + _ = c.DeleteObject(font); + } +} + +test "edge connectors resolve reordered node IDs to card positions" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("node-z"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + .{ .id = @constCast("node-a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + var state = CanvasState{}; + const from = connectorPosition(&nodes, "node-a", true, &state) orelse return error.MissingConnector; + const to = connectorPosition(&nodes, "node-z", false, &state) orelse return error.MissingConnector; + const expected_from = nodeBounds(1, &state); + const expected_to = nodeBounds(0, &state); + try std.testing.expectEqual(expected_from.right, from.x); + try std.testing.expectEqual(@divTrunc(expected_from.top + expected_from.bottom, 2), from.y); + try std.testing.expectEqual(expected_to.left, to.x); + try std.testing.expectEqual(@divTrunc(expected_to.top + expected_to.bottom, 2), to.y); + try std.testing.expect(connectorPosition(&nodes, "missing", true, &state) == null); +} + +test "canvas zoom keeps the graph point beneath the cursor stable" { + var state = CanvasState{}; + state.zoomAt(400, 300, 120); + try std.testing.expectApproxEqAbs(@as(f32, 1.1), state.zoom, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, -40), state.pan_x, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, -30), state.pan_y, 0.01); +} + +test "canvas hit testing follows pan and zoom" { + var state = CanvasState{}; + state.pan_x = 20; + state.pan_y = 10; + state.zoom = 1.2; + const nodes = [_]GraphModel.Node{}; + _ = nodes; + const expected = nodeBounds(0, &state); + const point = hitTest(&[_]GraphModel.Node{ + .{ .id = @constCast("a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }, expected.left + 2, expected.top + 2, &state, rect(Tokens.sidebar_width, Tokens.header_height, 1200, 700)); + try std.testing.expectEqual(@as(?usize, 0), point); +} + +test "direct node movement follows zoom and cancels safely" { + var state = CanvasState{ .zoom = 2 }; + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + state.syncNodeOffsets(&nodes); + const before = nodeBounds(0, &state); + state.beginNodeDrag("a", 0, before.left, before.top); + state.updateNodeDrag(before.left + 40, before.top + 20); + const moved = nodeBounds(0, &state); + try std.testing.expectEqual(before.left + 40, moved.left); + try std.testing.expectEqual(before.top + 20, moved.top); + state.cancelInteraction(); + const restored = nodeBounds(0, &state); + try std.testing.expectEqual(before.left, restored.left); + try std.testing.expectEqual(before.top, restored.top); + state.beginNodeDrag("a", 0, before.left, before.top); + state.updateNodeDrag(before.left + 30, before.top + 10); + state.endNodeDrag(); + try std.testing.expectEqual(before.left + 30, nodeBounds(0, &state).left); +} + +test "direct node movement follows stable node identity across daemon reorder" { + var state = CanvasState{}; + const initial = [_]GraphModel.Node{ + .{ .id = @constCast("a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + .{ .id = @constCast("b"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + state.syncNodeOffsets(&initial); + const before_a = nodeBounds(0, &state); + state.beginNodeDrag("a", 0, before_a.left, before_a.top); + state.updateNodeDrag(before_a.left + 30, before_a.top + 10); + state.endNodeDrag(); + + const reordered = [_]GraphModel.Node{ initial[1], initial[0] }; + state.syncNodeOffsets(&reordered); + const moved_a = nodeBounds(1, &state); + const unmoved_b = nodeBounds(0, &state); + try std.testing.expectEqual(@as(i32, Tokens.sidebar_width + 32 + 260 + 30), moved_a.left); + try std.testing.expectEqual(@as(i32, Tokens.header_height + 50 + 10), moved_a.top); + try std.testing.expectEqual(@as(i32, Tokens.sidebar_width + 32), unmoved_b.left); + try std.testing.expectEqual(@as(i32, Tokens.header_height + 50), unmoved_b.top); +} + +test "node movement persists by stable identity across canvas state reload" { + var state = CanvasState{}; + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("persisted"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + state.syncNodeOffsets(&nodes); + const before = nodeBounds(0, &state); + state.beginNodeDrag("persisted", 0, before.left, before.top); + state.updateNodeDrag(before.left + 45, before.top + 25); + state.endNodeDrag(); + const encoded = try state.encodeNodeOffsets(std.testing.allocator); + defer std.testing.allocator.free(encoded); + + var restored = CanvasState{}; + try restored.decodeNodeOffsets(encoded); + restored.syncNodeOffsets(&nodes); + try std.testing.expectEqual(before.left + 45, nodeBounds(0, &restored).left); + try std.testing.expectEqual(before.top + 25, nodeBounds(0, &restored).top); +} + +test "invalid persisted node movement is rejected without replacing valid state" { + var state = CanvasState{}; + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("valid"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + state.syncNodeOffsets(&nodes); + state.node_offsets[0] = .{ .x = 12, .y = 8 }; + try std.testing.expectError( + error.InvalidCanvasLayout, + state.decodeNodeOffsets("1\tnan\t4\n"), + ); + try std.testing.expectEqual(@as(i32, Tokens.sidebar_width + 32 + 12), nodeBounds(0, &state).left); + try std.testing.expectEqual(@as(i32, Tokens.header_height + 50 + 8), nodeBounds(0, &state).top); +} + +test "overview and quick chat hit testing follows rendered cards" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Loop A","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + var state = CanvasState{ .pan_y = 17 }; + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800); + const card = overviewCardBounds(&model, 0, 0, bounds, &state); + const hit = hitTestOverview(&model, card.left + 4, card.top + 4, &state, bounds) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 0), hit.graph_index); + try std.testing.expectEqual(@as(usize, 0), hit.node_index); + const chat = quickChatCardBounds(2, bounds, &state); + try std.testing.expectEqual(@as(?usize, 2), hitTestQuickChat(3, chat.left + 4, chat.top + 4, &state, bounds)); + try std.testing.expect(hitTestQuickChat(3, bounds.left - 1, chat.top, &state, bounds) == null); +} + +test "overview and quick chat geometry applies pan and zoom consistently" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + var graph = GraphModel.GraphSummary{ + .project = .{ + .path = try std.testing.allocator.dupe(u8, "graphcode://test"), + .name = try std.testing.allocator.dupe(u8, "Test"), + }, + .nodes = std.array_list.Managed(GraphModel.Node).init(std.testing.allocator), + .edges = std.array_list.Managed(GraphModel.Edge).init(std.testing.allocator), + }; + try graph.nodes.append(.{ + .id = try std.testing.allocator.dupe(u8, "node"), + .title = try std.testing.allocator.dupe(u8, "Node"), + .loop_type = try std.testing.allocator.dupe(u8, "turnBased"), + .state = try std.testing.allocator.dupe(u8, "idle"), + .activity = try std.testing.allocator.dupe(u8, ""), + .presence = try std.testing.allocator.dupe(u8, "idle"), + }); + try model.graphs.append(graph); + const bounds = rect(240, 42, 1200, 800); + var state = CanvasState{ .pan_x = 25, .pan_y = -12, .zoom = 1.25 }; + const card = overviewCardBounds(&model, 0, 0, bounds, &state); + try std.testing.expectEqual(@as(?OverviewHit, .{ .graph_index = 0, .node_index = 0 }), hitTestOverview(&model, card.left + 2, card.top + 2, &state, bounds)); + const chat = quickChatCardBounds(0, bounds, &state); + try std.testing.expectEqual(@as(?usize, 0), hitTestQuickChat(1, chat.left + 2, chat.top + 2, &state, bounds)); +} + +test "zoom controls expose every action and fit content" { + const bounds = rect(240, 42, 1200, 800); + inline for ([_]ZoomControl{ .out, .actual, .in, .fit }, 0..) |expected, index| { + const button = zoomButtonBounds(bounds, @intCast(index)); + try std.testing.expectEqual(expected, hitTestZoomControl(button.left + 2, button.top + 2, bounds).?); + } + var state = CanvasState{}; + state.fit(bounds, 1600, 900); + try std.testing.expect(state.zoom < 1); +} + +test "minimum zoom hides overflow-prone card content" { + const layout = cardTextLayout(0.55, true, true); + try std.testing.expect(!layout.show_entry); + try std.testing.expect(!layout.show_activity); + try std.testing.expect(!layout.show_attention); + try std.testing.expect(layout.state_y < @as(i32, @intFromFloat(106 * 0.55))); +} + +test "loop card detail prioritizes goal and preserves metadata" { + const node = GraphModel.Node{ + .id = @constCast("node"), + .title = @constCast("Goal"), + .loop_type = @constCast("goalBased"), + .state = @constCast("running"), + .activity = @constCast("checking tests"), + .presence = @constCast("busy"), + .goal_summary = @constCast("All tests pass"), + .model_tier = @constCast("capable"), + .worktree_branch = @constCast("feature/parity"), + }; + try std.testing.expectEqualStrings("All tests pass", nodePrimaryDetail(node)); + var buffer: [128]u8 = undefined; + try std.testing.expectEqualStrings("feature/parity · capable", nodeMetadata(&buffer, node)); +} + +test "unwired roles require explicit session entry acknowledgement" { + const no_edges = [_]GraphModel.Edge{}; + try std.testing.expectEqual(NodeRole.unwired, nodeRole(&no_edges, "loose", &.{})); + try std.testing.expectEqual(NodeRole.entry, nodeRole(&no_edges, "loose", &.{"loose"})); + const edge = [_]GraphModel.Edge{.{ + .from = @constCast("source"), + .to = @constCast("target"), + }}; + try std.testing.expectEqual(NodeRole.entry, nodeRole(&edge, "source", &.{})); + try std.testing.expectEqual(NodeRole.interior, nodeRole(&edge, "target", &.{})); +} + +test "safe resolved worktrees expose distinct reclaim and keep targets" { + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(std.testing.allocator), + .default_branch = @constCast("main"), + .project_path = @constCast("project"), + }; + defer inspection.entries.deinit(); + try inspection.entries.append(.{ + .path = @constCast("C:\\safe"), + .branch = @constCast("done"), + .pushed = true, + .landed = true, + }); + const node = GraphModel.Node{ + .id = @constCast("node"), + .title = @constCast("Done"), + .loop_type = @constCast("turnBased"), + .state = @constCast("succeeded"), + .activity = @constCast(""), + .presence = @constCast("idle"), + .worktree_path = @constCast("C:\\safe"), + }; + try std.testing.expect(hasReclaimOffer(node, &inspection, &.{})); + try std.testing.expect(!hasReclaimOffer(node, &inspection, &.{"C:\\safe"})); + const nodes = [_]GraphModel.Node{node}; + const offer = reclaimOfferBounds(nodeBounds(0, &.{})); + try std.testing.expectEqual(ReclaimAction.reclaim, hitTestReclaimOffer(&nodes, &inspection, &.{}, offer.reclaim.left + 1, offer.reclaim.top + 1, &.{}).?.action); + try std.testing.expectEqual(ReclaimAction.keep, hitTestReclaimOffer(&nodes, &inspection, &.{}, offer.keep.left + 1, offer.keep.top + 1, &.{}).?.action); +} + +test "attention follows awaiting input and stranded blocked semantics" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("awaiting"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast("running"), .activity = @constCast(""), .presence = @constCast("awaitingInput") }, + .{ .id = @constCast("failed"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast("failed"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("blocked"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast("blocked"), .activity = @constCast(""), .presence = @constCast("idle") }, + }; + const edges = [_]GraphModel.Edge{ + .{ .from = @constCast("failed"), .to = @constCast("blocked"), .kind = @constCast("handoff"), .fired = false }, + }; + try std.testing.expect(needsAttention(nodes[0], &nodes, &edges)); + try std.testing.expect(needsAttention(nodes[2], &nodes, &edges)); + const unresolved_nodes = [_]GraphModel.Node{ + nodes[1], + .{ .id = @constCast("running"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast("running"), .activity = @constCast(""), .presence = @constCast("busy") }, + nodes[2], + }; + const unresolved_edges = [_]GraphModel.Edge{ + edges[0], + .{ .from = @constCast("running"), .to = @constCast("blocked"), .kind = @constCast("handoff"), .fired = false }, + }; + try std.testing.expect(!needsAttention(unresolved_nodes[2], &unresolved_nodes, &unresolved_edges)); + const ordinary_waiting = GraphModel.Node{ + .id = @constCast("waiting"), + .title = @constCast(""), + .loop_type = @constCast(""), + .state = @constCast("waiting"), + .activity = @constCast(""), + .presence = @constCast("waiting"), + }; + try std.testing.expect(!needsAttention(ordinary_waiting, &nodes, &edges)); +} + +test "hit testing rejects cards outside the graph viewport" { + var state = CanvasState{}; + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 900, 500); + try std.testing.expect(hitTest(&nodes, 10, 100, &state, bounds) == null); + try std.testing.expect(hitTest(&nodes, 300, 20, &state, bounds) == null); +} + +test "edge hit testing follows reordered endpoint IDs and zoom pan" { + var state = CanvasState{ .pan_x = 18, .pan_y = -7, .zoom = 1.15 }; + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("target"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + .{ .id = @constCast("source"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + const edges = [_]GraphModel.Edge{ + .{ .from = @constCast("source"), .to = @constCast("target"), .kind = @constCast("handoff") }, + }; + const from = connectorPosition(&nodes, "source", true, &state) orelse return error.MissingConnector; + const to = connectorPosition(&nodes, "target", false, &state) orelse return error.MissingConnector; + const distance: i32 = if (to.x >= from.x) to.x - from.x else from.x - to.x; + const bend: i32 = @max(@as(i32, 24), @divTrunc(distance, 2)); + const midpoint = Connector{ + .x = @intFromFloat( + @as(f32, @floatFromInt(from.x)) * 0.125 + + @as(f32, @floatFromInt(from.x + bend)) * 0.375 + + @as(f32, @floatFromInt(to.x - bend)) * 0.375 + + @as(f32, @floatFromInt(to.x)) * 0.125, + ), + .y = @intFromFloat((@as(f32, @floatFromInt(from.y)) + @as(f32, @floatFromInt(to.y))) / 2), + }; + const bounds = rect(Tokens.sidebar_width, Tokens.header_height, 1200, 800); + try std.testing.expectEqual(@as(?usize, 0), hitTestEdge(&nodes, &edges, midpoint.x, midpoint.y, &state, bounds)); +} + +test "edge drag state cancels without leaving a selection" { + var state = CanvasState{}; + state.beginEdgeDrag("node-3", 100, 120); + state.updateEdgeDrag(140, 160); + try std.testing.expectEqualStrings("node-3", state.endEdgeDrag().?); + try std.testing.expect(!state.edge_dragging); + try std.testing.expectEqualStrings("", state.edge_drag_source_id); +} + +test "edge presentation distinguishes kind condition and fired state" { + var buffer: [128]u8 = undefined; + const message = GraphModel.Edge{ + .id = @constCast("edge"), + .from = @constCast("a"), + .to = @constCast("b"), + .kind = @constCast("message"), + .condition = @constCast("onSuccess"), + .fire_count = 2, + }; + try std.testing.expectEqual(c.PS_DOT, edgeKindPenStyle(message.kind)); + try std.testing.expectEqualStrings("message · onSuccess · fired 2", edgeLabel(&buffer, message)); + try std.testing.expect(edgeKindColor("message") != edgeKindColor("spawn")); +} + +test "capture loss cancels both pan and edge drag state" { + var state = CanvasState{}; + state.beginPan(10, 20); + state.beginEdgeDrag("node-1", 30, 40); + state.cancelInteraction(); + try std.testing.expect(!state.dragging); + try std.testing.expect(!state.edge_dragging); + try std.testing.expectEqualStrings("", state.edge_drag_source_id); +} diff --git a/graphcode-windows/src/GraphContextMenu.zig b/graphcode-windows/src/GraphContextMenu.zig new file mode 100644 index 00000000..abd136cb --- /dev/null +++ b/graphcode-windows/src/GraphContextMenu.zig @@ -0,0 +1,299 @@ +const c = @import("Win32.zig").c; + +pub const NodeTarget = struct { + project_path: []const u8, + id: []const u8, + composite: bool = false, + can_arm: bool = false, + unwired: bool = false, +}; + +pub const EdgeTarget = struct { + project_path: []const u8, + id: []const u8, +}; + +pub const QuickChatTarget = struct { + id: []const u8, +}; + +pub const ProjectTarget = struct { + path: []const u8, + remote: bool, +}; + +pub const Target = union(enum) { + background, + quick_chats, + project: ProjectTarget, + node: NodeTarget, + edge: EdgeTarget, + quick_chat: QuickChatTarget, +}; + +pub const Action = enum { + none, + rename_node, + stop_node, + delete_node, + open_terminal, + message_node, + memo_node, + open_composite, + pilot_composite, + arm_composite, + wire_node, + mark_entry, + edit_edge, + delete_edge, + create_edge, + open_quick_chat, + rename_quick_chat, + delete_quick_chat, + open_project, + new_project_loop, + inspect_project_worktrees, + project_settings, + reveal_project, + remote_project_info, + close_project, + remove_project, + delete_project_loops, + new_quick_chat, +}; + +pub const Callback = *const fn (?*anyopaque, Action, Target) void; + +pub fn requiresConfirmation(action: Action) bool { + return action == .delete_node or action == .delete_edge or action == .delete_quick_chat or + action == .remove_project or action == .delete_project_loops; +} + +pub fn shouldApply(action: Action, confirmed: bool) bool { + return !requiresConfirmation(action) or confirmed; +} + +pub fn canEditEdge(edge_id: []const u8) bool { + return edge_id.len != 0; +} + +const ids = struct { + const rename_node = 5101; + const stop_node = 5102; + const delete_node = 5103; + const open_terminal = 5104; + const message_node = 5105; + const memo_node = 5106; + const open_composite = 5113; + const pilot_composite = 5107; + const arm_composite = 5108; + const wire_node = 5109; + const mark_entry = 5112; + const edit_edge = 5110; + const delete_edge = 5111; + const create_edge = 5120; + const open_quick_chat = 5130; + const rename_quick_chat = 5131; + const delete_quick_chat = 5132; + const open_project = 5140; + const new_project_loop = 5141; + const inspect_project_worktrees = 5142; + const project_settings = 5143; + const reveal_project = 5144; + const remote_project_info = 5145; + const close_project = 5146; + const remove_project = 5147; + const delete_project_loops = 5148; + const new_quick_chat = 5150; +}; + +pub fn show( + parent: c.HWND, + target: Target, + x: i32, + y: i32, + context: ?*anyopaque, + callback: Callback, +) void { + const menu = c.CreatePopupMenu() orelse return; + defer _ = c.DestroyMenu(menu); + switch (target) { + .background => append(menu, ids.create_edge, "Create Edge"), + .quick_chats => append(menu, ids.new_quick_chat, "New Chat"), + .project => |project| { + append(menu, ids.open_project, "Open Project"); + append(menu, ids.new_project_loop, "New Loop..."); + separator(menu); + append(menu, ids.inspect_project_worktrees, "Worktrees..."); + append(menu, ids.project_settings, "Project Settings..."); + if (project.remote) + append(menu, ids.remote_project_info, "Remote Connection Info") + else + append(menu, ids.reveal_project, "Show in Explorer"); + separator(menu); + append(menu, ids.close_project, "Close Project"); + append(menu, ids.remove_project, "Remove from GraphCode..."); + append(menu, ids.delete_project_loops, "Delete All Loops..."); + }, + .node => |node| { + append(menu, ids.open_terminal, "Open Terminal"); + if (node.unwired) { + append(menu, ids.wire_node, "Wire it up"); + append(menu, ids.mark_entry, "Mark as entry"); + separator(menu); + } + if (node.composite) { + append(menu, ids.open_composite, "Open Group"); + append(menu, ids.pilot_composite, "Pilot Once"); + appendEnabled(menu, ids.arm_composite, "Arm Schedule", node.can_arm); + separator(menu); + } + append(menu, ids.message_node, "Message"); + append(menu, ids.memo_node, "Memo"); + append(menu, ids.rename_node, "Rename..."); + append(menu, ids.stop_node, "Stop"); + append(menu, ids.delete_node, "Delete Loop..."); + }, + .edge => { + append(menu, ids.edit_edge, "Edit Edge..."); + append(menu, ids.delete_edge, "Delete Edge"); + }, + .quick_chat => { + append(menu, ids.open_quick_chat, "Open Chat"); + append(menu, ids.rename_quick_chat, "Rename..."); + append(menu, ids.delete_quick_chat, "Delete Chat..."); + }, + } + const command = c.TrackPopupMenu( + menu, + c.TPM_RETURNCMD | c.TPM_NONOTIFY | c.TPM_RIGHTBUTTON, + x, + y, + 0, + parent, + null, + ); + const action = actionForCommand(command); + if (action != .none) callback(context, action, target); +} + +pub fn confirm(parent: c.HWND, title: []const u8, message: []const u8) bool { + const title_wide = toWide(title) orelse return false; + defer std.heap.c_allocator.free(title_wide); + const message_wide = toWide(message) orelse return false; + defer std.heap.c_allocator.free(message_wide); + return c.MessageBoxW(parent, message_wide.ptr, title_wide.ptr, c.MB_ICONWARNING | c.MB_YESNO | c.MB_DEFBUTTON2) == c.IDYES; +} + +fn actionForCommand(command: c_int) Action { + return switch (command) { + ids.rename_node => .rename_node, + ids.stop_node => .stop_node, + ids.delete_node => .delete_node, + ids.open_terminal => .open_terminal, + ids.message_node => .message_node, + ids.memo_node => .memo_node, + ids.open_composite => .open_composite, + ids.pilot_composite => .pilot_composite, + ids.arm_composite => .arm_composite, + ids.wire_node => .wire_node, + ids.mark_entry => .mark_entry, + ids.edit_edge => .edit_edge, + ids.delete_edge => .delete_edge, + ids.create_edge => .create_edge, + ids.open_quick_chat => .open_quick_chat, + ids.rename_quick_chat => .rename_quick_chat, + ids.delete_quick_chat => .delete_quick_chat, + ids.open_project => .open_project, + ids.new_project_loop => .new_project_loop, + ids.inspect_project_worktrees => .inspect_project_worktrees, + ids.project_settings => .project_settings, + ids.reveal_project => .reveal_project, + ids.remote_project_info => .remote_project_info, + ids.close_project => .close_project, + ids.remove_project => .remove_project, + ids.delete_project_loops => .delete_project_loops, + ids.new_quick_chat => .new_quick_chat, + else => .none, + }; +} + +fn append(menu: c.HMENU, id: usize, text: []const u8) void { + appendEnabled(menu, id, text, true); +} + +fn appendEnabled(menu: c.HMENU, id: usize, text: []const u8, enabled: bool) void { + const wide = toWide(text) orelse return; + defer std.heap.c_allocator.free(wide); + var flags: c.UINT = @intCast(c.MF_STRING); + if (!enabled) flags |= @intCast(c.MF_GRAYED); + _ = c.AppendMenuW(menu, flags, id, wide.ptr); +} + +fn separator(menu: c.HMENU) void { + _ = c.AppendMenuW(menu, c.MF_SEPARATOR, 0, null); +} + +fn toWide(text: []const u8) ?[]u16 { + const raw = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return null; + const result = std.heap.c_allocator.alloc(u16, raw.len + 1) catch { + std.heap.c_allocator.free(raw); + return null; + }; + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + std.heap.c_allocator.free(raw); + return result; +} + +const std = @import("std"); + +test "context actions remain stable when graph IDs are reordered" { + try std.testing.expectEqual(Action.rename_node, actionForCommand(ids.rename_node)); + try std.testing.expectEqual(Action.delete_edge, actionForCommand(ids.delete_edge)); + try std.testing.expectEqual(Action.none, actionForCommand(0)); + try std.testing.expectEqual(Action.pilot_composite, actionForCommand(ids.pilot_composite)); + try std.testing.expectEqual(Action.open_composite, actionForCommand(ids.open_composite)); + try std.testing.expectEqual(Action.arm_composite, actionForCommand(ids.arm_composite)); + try std.testing.expectEqual(Action.wire_node, actionForCommand(ids.wire_node)); + try std.testing.expectEqual(Action.mark_entry, actionForCommand(ids.mark_entry)); +} + +test "destructive context actions cannot bypass a cancelled confirmation" { + try std.testing.expect(!shouldApply(.delete_node, false)); + try std.testing.expect(!shouldApply(.delete_edge, false)); + try std.testing.expect(!shouldApply(.delete_quick_chat, false)); + try std.testing.expect(!shouldApply(.remove_project, false)); + try std.testing.expect(!shouldApply(.delete_project_loops, false)); + try std.testing.expect(shouldApply(.rename_node, false)); +} + +test "quick chat context targets preserve stable identity" { + const target = QuickChatTarget{ .id = "chat-a" }; + try std.testing.expectEqualStrings("chat-a", target.id); + try std.testing.expectEqual(Action.open_quick_chat, actionForCommand(ids.open_quick_chat)); + try std.testing.expectEqual(Action.rename_quick_chat, actionForCommand(ids.rename_quick_chat)); + try std.testing.expectEqual(Action.delete_quick_chat, actionForCommand(ids.delete_quick_chat)); +} + +test "edge editing requires a stable edge identifier" { + try std.testing.expect(!canEditEdge("")); + try std.testing.expect(canEditEdge("edge-1")); +} + +test "context targets carry stable copied identity rather than collection indices" { + const node = NodeTarget{ .project_path = "C:\\work\\graph", .id = "node-a" }; + const edge = EdgeTarget{ .project_path = "C:\\work\\graph", .id = "edge-a" }; + try std.testing.expectEqualStrings("node-a", node.id); + try std.testing.expectEqualStrings("edge-a", edge.id); + try std.testing.expectEqualStrings("C:\\work\\graph", edge.project_path); +} + +test "project context commands expose ingress management and safe destructive actions" { + const target = ProjectTarget{ .path = "C:\\work\\graph", .remote = false }; + try std.testing.expectEqualStrings("C:\\work\\graph", target.path); + try std.testing.expectEqual(Action.open_project, actionForCommand(ids.open_project)); + try std.testing.expectEqual(Action.project_settings, actionForCommand(ids.project_settings)); + try std.testing.expectEqual(Action.remove_project, actionForCommand(ids.remove_project)); + try std.testing.expectEqual(Action.delete_project_loops, actionForCommand(ids.delete_project_loops)); +} diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig new file mode 100644 index 00000000..8b35c579 --- /dev/null +++ b/graphcode-windows/src/GraphModel.zig @@ -0,0 +1,1855 @@ +const std = @import("std"); +const Wire = @import("Wire.zig"); +pub const WorktreeSummary = @import("WorktreeStatus.zig").Summary; + +pub const Node = struct { + id: []u8, + title: []u8, + loop_type: []u8, + state: []u8, + activity: []u8, + presence: []u8, + pilot_state: []u8 = &.{}, + goal_summary: []u8 = &.{}, + goal_predicate: []u8 = &.{}, + metric_command: []u8 = &.{}, + metric_direction: []u8 = &.{}, + trigger_prompt: []u8 = &.{}, + check_description: []u8 = &.{}, + model_tier: []u8 = &.{}, + poll_interval_seconds: ?f64 = null, + stall_after_seconds: ?f64 = null, + worktree_path: []u8 = @constCast(""), + worktree_branch: []u8 = &.{}, + subgraph_json: []u8 = &.{}, +}; + +pub const ActivityEvent = struct { + title: []u8, + state: []u8, +}; + +pub const QuickChat = struct { + id: []u8, + title: []u8, + backend: []u8, + activity: []u8 = &.{}, + activity_sequence: u64 = 0, +}; + +pub const Edge = struct { + id: []u8 = &.{}, + from: []u8, + to: []u8, + kind: []u8 = &.{}, + condition: []u8 = @constCast("always"), + blocks_target: bool = true, + fired: bool = false, + fire_count: u32 = 0, +}; + +pub const Project = struct { + path: []u8, + name: []u8, + + pub fn isRemote(self: Project) bool { + return std.mem.startsWith(u8, self.path, "ssh://"); + } + + pub fn isGlobal(self: Project) bool { + return std.mem.eql(u8, self.path, "graphcode://global"); + } + + pub fn isLocalFilesystem(self: Project) bool { + return !self.isRemote() and !self.isGlobal() and std.mem.indexOf(u8, self.path, "://") == null; + } +}; + +pub const Graph = struct { + project: Project, + nodes: std.array_list.Managed(Node), + edges: std.array_list.Managed(Edge), +}; + +pub const AttentionEntry = struct { + project_path: []u8, + node: Node, +}; + +pub const GraphGeneration = struct { + project_path: []u8, + generation: u64, +}; + +pub const LifecycleAction = enum { + select, + close, + forget, + delete, +}; + +pub const LifecycleRequest = struct { + action: LifecycleAction, + project_path: []const u8, +}; + +pub const LifecycleCallback = *const fn (context: ?*anyopaque, request: LifecycleRequest) void; + +pub const RestoreState = enum { + cold, + restoring, + restored, + reconnecting, +}; + +const LifecycleProbe = struct { + path: [64]u8 = undefined, + path_len: usize = 0, + called: bool = false, +}; + +fn lifecycleProbeCallback(context: ?*anyopaque, request: LifecycleRequest) void { + const probe: *LifecycleProbe = @ptrCast(@alignCast(context.?)); + probe.path_len = request.project_path.len; + @memcpy(probe.path[0..probe.path_len], request.project_path); + probe.called = true; +} + +pub const GraphSummary = struct { + project: Project, + nodes: std.array_list.Managed(Node), + edges: std.array_list.Managed(Edge), + + fn deinit(self: *GraphSummary, allocator: std.mem.Allocator) void { + freeProject(allocator, self.project); + for (self.nodes.items) |node| freeNode(allocator, node); + for (self.edges.items) |edge| freeEdge(allocator, edge); + self.nodes.deinit(); + self.edges.deinit(); + } +}; + +pub const Model = struct { + allocator: std.mem.Allocator, + recent_projects: std.array_list.Managed(Project), + open_projects: std.array_list.Managed(Project), + graphs: std.array_list.Managed(GraphSummary), + graph: ?Graph = null, + selected_project_path: ?[]u8 = null, + selected_node_id: ?[]u8 = null, + selected_index: ?usize = null, + open_composite_id: ?[]u8 = null, + open_composite_title: ?[]u8 = null, + last_sequence: u64 = 0, + attention: std.array_list.Managed(Node), + attention_entries: std.array_list.Managed(AttentionEntry), + activity: std.array_list.Managed(ActivityEvent), + lifecycle_callback: ?LifecycleCallback = null, + lifecycle_context: ?*anyopaque = null, + restore_state: RestoreState = .cold, + restore_generation: u64 = 0, + graph_generations: std.array_list.Managed(GraphGeneration), + quick_chats: std.array_list.Managed(QuickChat), + + pub fn init(allocator: std.mem.Allocator) Model { + return .{ + .allocator = allocator, + .recent_projects = std.array_list.Managed(Project).init(allocator), + .open_projects = std.array_list.Managed(Project).init(allocator), + .graphs = std.array_list.Managed(GraphSummary).init(allocator), + .attention = std.array_list.Managed(Node).init(allocator), + .attention_entries = std.array_list.Managed(AttentionEntry).init(allocator), + .activity = std.array_list.Managed(ActivityEvent).init(allocator), + .graph_generations = std.array_list.Managed(GraphGeneration).init(allocator), + .quick_chats = std.array_list.Managed(QuickChat).init(allocator), + }; + } + + pub fn deinit(self: *Model) void { + for (self.recent_projects.items) |project| freeProject(self.allocator, project); + self.recent_projects.deinit(); + for (self.open_projects.items) |project| freeProject(self.allocator, project); + self.open_projects.deinit(); + for (self.graphs.items) |*summary| summary.deinit(self.allocator); + self.graphs.deinit(); + for (self.attention.items) |node| freeNode(self.allocator, node); + self.attention.deinit(); + for (self.attention_entries.items) |entry| freeAttentionEntry(self.allocator, entry); + self.attention_entries.deinit(); + for (self.activity.items) |event| { + self.allocator.free(event.title); + self.allocator.free(event.state); + } + self.activity.deinit(); + for (self.quick_chats.items) |chat| freeQuickChat(self.allocator, chat); + self.quick_chats.deinit(); + if (self.graph) |*graph| freeGraph(self.allocator, graph); + if (self.selected_project_path) |path| self.allocator.free(path); + self.freeSelectedNodeID(); + self.clearOpenComposite(); + for (self.graph_generations.items) |entry| self.allocator.free(entry.project_path); + self.graph_generations.deinit(); + } + + pub fn clearProjects(self: *Model) void { + for (self.recent_projects.items) |project| freeProject(self.allocator, project); + self.recent_projects.clearRetainingCapacity(); + } + + pub fn setLifecycleCallback(self: *Model, context: ?*anyopaque, callback: ?LifecycleCallback) void { + self.lifecycle_context = context; + self.lifecycle_callback = callback; + } + + pub fn dispatchLifecycle(self: *Model, action: LifecycleAction, project_path: []const u8) void { + if (self.lifecycle_callback) |callback| { + callback(self.lifecycle_context, .{ .action = action, .project_path = project_path }); + } + } + + pub fn beginRestore(self: *Model) void { + self.restore_generation += 1; + self.restore_state = .restoring; + } + + pub fn markRestored(self: *Model) void { + self.reconcileRestore(); + self.restore_state = .restored; + } + + pub fn markReconnecting(self: *Model) void { + // Graph summaries and selection intentionally survive transport loss. + self.restore_state = .reconnecting; + } + + pub fn selectedNodeID(self: *const Model) ?[]const u8 { + return if (self.selected()) |node| node.id else null; + } + + pub fn selectedIndex(self: *const Model) ?usize { + return self.selected_index; + } + + pub fn setSelectedIndex(self: *Model, index: ?usize) bool { + const graph = if (self.graph) |*value| value else return index == null; + if (index) |value| { + if (value >= graph.nodes.items.len) return false; + self.selected_index = value; + self.replaceSelectedNodeID(graph.nodes.items[value].id); + } else { + self.selected_index = null; + self.freeSelectedNodeID(); + } + return true; + } + + pub fn setSelectedID(self: *Model, id: []const u8) bool { + return self.selectNodeID(id); + } + + fn freeSelectedNodeID(self: *Model) void { + if (self.selected_node_id) |id| self.allocator.free(id); + self.selected_node_id = null; + } + + fn replaceSelectedNodeID(self: *Model, id: []const u8) void { + const copy = self.allocator.dupe(u8, id) catch return; + self.freeSelectedNodeID(); + self.selected_node_id = copy; + } + + pub fn currentGraph(self: *const Model) ?*const GraphSummary { + if (self.selected_project_path) |path| return self.graphFor(path); + if (self.graph) |graph| return self.graphFor(graph.project.path); + return null; + } + + pub fn graphFor(self: *const Model, project_path: []const u8) ?*const GraphSummary { + for (self.graphs.items) |*summary| { + if (std.mem.eql(u8, summary.project.path, project_path)) return summary; + } + return null; + } + + pub fn selectProject(self: *Model, project_path: []const u8) bool { + const summary = self.graphFor(project_path) orelse return false; + self.clearOpenComposite(); + if (self.selected_project_path) |path| self.allocator.free(path); + self.selected_project_path = self.allocator.dupe(u8, summary.project.path) catch return false; + self.selected_index = if (summary.nodes.items.len == 0) null else 0; + if (summary.nodes.items.len == 0) self.freeSelectedNodeID() else self.replaceSelectedNodeID(summary.nodes.items[0].id); + self.syncLegacyGraph(); + return true; + } + + pub fn openComposite(self: *Model, node_id: []const u8) bool { + const summary = self.currentGraph() orelse return false; + const index = findNodeIndexByID(summary.nodes.items, node_id) orelse return false; + const node = summary.nodes.items[index]; + if ((!std.mem.eql(u8, node.loop_type, "composite") and + !std.mem.eql(u8, node.loop_type, "proactive")) or node.subgraph_json.len == 0) + return false; + const id = self.allocator.dupe(u8, node.id) catch return false; + errdefer self.allocator.free(id); + const title = self.allocator.dupe(u8, node.title) catch return false; + self.clearOpenComposite(); + self.open_composite_id = id; + self.open_composite_title = title; + self.syncLegacyGraph(); + return self.graph != null; + } + + pub fn closeComposite(self: *Model) void { + const parent_id = if (self.open_composite_id) |id| + self.allocator.dupe(u8, id) catch null + else + null; + defer if (parent_id) |id| self.allocator.free(id); + self.clearOpenComposite(); + self.syncLegacyGraph(); + if (parent_id) |id| _ = self.selectNodeID(id); + } + + pub fn isCompositeOpen(self: *const Model) bool { + return self.open_composite_id != null; + } + + fn clearOpenComposite(self: *Model) void { + if (self.open_composite_id) |id| self.allocator.free(id); + if (self.open_composite_title) |title| self.allocator.free(title); + self.open_composite_id = null; + self.open_composite_title = null; + } + + pub fn reconcileRestore(self: *Model) void { + var index: usize = 0; + while (index < self.graphs.items.len) { + const path = self.graphs.items[index].project.path; + if (!self.wasGraphSeen(path)) { + self.removeOpenProject(path); + var removed = self.graphs.orderedRemove(index); + removed.deinit(self.allocator); + continue; + } + index += 1; + } + if (self.selected_project_path) |path| { + if (self.graphFor(path) != null) { + self.syncLegacyGraph(); + self.rebuildAttention(); + return; + } + self.allocator.free(path); + self.selected_project_path = null; + } + if (self.graphs.items.len != 0) { + _ = self.selectProject(self.graphs.items[0].project.path); + } else { + self.selected_index = null; + self.freeSelectedNodeID(); + } + self.syncLegacyGraph(); + self.rebuildAttention(); + } + + pub fn applyLifecycle(self: *Model, action: LifecycleAction, project_path: []const u8) bool { + const stable_path = self.allocator.dupe(u8, project_path) catch return false; + defer self.allocator.free(stable_path); + const path = stable_path; + const index = for (self.graphs.items, 0..) |summary, i| { + if (std.mem.eql(u8, summary.project.path, path)) break i; + } else null; + if (action == .select) { + const selected_result = self.selectProject(path); + if (selected_result) self.dispatchLifecycle(action, path); + return selected_result; + } + var known = index != null; + for (self.open_projects.items) |project| known = known or std.mem.eql(u8, project.path, path); + for (self.recent_projects.items) |project| known = known or std.mem.eql(u8, project.path, path); + if (!known) return false; + switch (action) { + .select => unreachable, + .close => { + if (index) |i| { + var summary = self.graphs.orderedRemove(i); + summary.deinit(self.allocator); + } + self.removeOpenProject(path); + }, + .forget => { + if (index) |i| { + var summary = self.graphs.orderedRemove(i); + summary.deinit(self.allocator); + } + self.removeOpenProject(path); + self.removeRecentProject(path); + }, + .delete => { + if (index) |i| { + var summary = self.graphs.orderedRemove(i); + summary.deinit(self.allocator); + } + self.removeOpenProject(path); + self.removeRecentProject(path); + }, + } + if (self.selected_project_path) |selected_path| { + if (std.mem.eql(u8, selected_path, path)) { + self.allocator.free(selected_path); + self.selected_project_path = null; + if (self.graph) |*graph| { + if (std.mem.eql(u8, graph.project.path, path)) { + freeGraph(self.allocator, graph); + self.graph = null; + } + } + self.selected_index = null; + self.freeSelectedNodeID(); + if (self.graphs.items.len != 0) { + _ = self.selectProject(self.graphs.items[0].project.path); + } + } + } + self.syncLegacyGraph(); + self.rebuildAttention(); + self.dispatchLifecycle(action, path); + return true; + } + + fn removeOpenProject(self: *Model, path: []const u8) void { + var i: usize = 0; + while (i < self.open_projects.items.len) { + if (std.mem.eql(u8, self.open_projects.items[i].path, path)) { + const project = self.open_projects.orderedRemove(i); + freeProject(self.allocator, project); + } else i += 1; + } + } + + fn removeRecentProject(self: *Model, path: []const u8) void { + var i: usize = 0; + while (i < self.recent_projects.items.len) { + if (std.mem.eql(u8, self.recent_projects.items[i].path, path)) { + const project = self.recent_projects.orderedRemove(i); + freeProject(self.allocator, project); + } else i += 1; + } + } + + pub fn updateFromFrame(self: *Model, frame: []const u8) !Wire.EventKind { + if (Wire.jsonNumber(frame, "sequence")) |sequence| self.last_sequence = sequence; + switch (Wire.eventKind(frame)) { + .graph_changed => { + try self.decodeGraph(frame); + return .graph_changed; + }, + .recent_projects => { + try self.decodeRecentProjects(frame); + return .recent_projects; + }, + .quick_chats, .quick_chat_changed, .quick_chat_deleted, .quick_chat_activity => { + try self.decodeQuickChats(frame, Wire.eventKind(frame)); + return Wire.eventKind(frame); + }, + else => return Wire.eventKind(frame), + } + } + + pub fn attentionCount(self: *const Model) usize { + return self.attention.items.len; + } + + pub fn selected(self: *const Model) ?*const Node { + const graph = if (self.graph) |*value| value else return null; + if (self.selected_node_id) |id| { + for (graph.nodes.items) |*node| if (std.mem.eql(u8, node.id, id)) return node; + } + const index = self.selected_index orelse return null; + if (index >= graph.nodes.items.len) return null; + return &graph.nodes.items[index]; + } + + pub fn findNodeIndex(self: *const Model, id: []const u8) ?usize { + const graph = self.graph orelse return null; + return findNodeIndexByID(graph.nodes.items, id); + } + + pub fn findEdgeIndex(self: *const Model, id: []const u8) ?usize { + const graph = self.graph orelse return null; + return findEdgeIndexByID(graph.edges.items, id); + } + + pub fn selectNext(self: *Model) void { + const graph = self.graph orelse return; + if (graph.nodes.items.len == 0) { + self.selected_index = null; + } else { + self.selected_index = ((self.selected_index orelse 0) + 1) % graph.nodes.items.len; + self.replaceSelectedNodeID(graph.nodes.items[self.selected_index.?].id); + } + } + + pub fn selectNextAttention(self: *Model) void { + if (self.attention_entries.items.len == 0) return; + var next_index: usize = 0; + if (self.selected_project_path) |project_path| { + var current_node_id = self.selected_node_id; + if (self.currentGraph()) |graph| { + if (self.selected_index) |index| { + if (index < graph.nodes.items.len) current_node_id = graph.nodes.items[index].id; + } + } + if (current_node_id) |node_id| { + for (self.attention_entries.items, 0..) |entry, index| { + if (std.mem.eql(u8, entry.project_path, project_path) and + std.mem.eql(u8, entry.node.id, node_id)) + { + next_index = (index + 1) % self.attention_entries.items.len; + break; + } + } + } + } + const next = self.attention_entries.items[next_index]; + if (!self.selectProject(next.project_path)) return; + _ = self.selectNodeID(next.node.id); + } + + fn selectNodeID(self: *Model, node_id: []const u8) bool { + const graph = self.graph orelse return false; + for (graph.nodes.items, 0..) |node, index| { + if (std.mem.eql(u8, node.id, node_id)) { + _ = self.setSelectedIndex(index); + return true; + } + } + return false; + } + + fn decodeRecentProjects(self: *Model, frame: []const u8) !void { + self.clearProjects(); + const list = std.mem.indexOf(u8, frame, "\"recentProjectsListed\"") orelse return; + const open = indexOfByte(frame, list, '[') orelse return; + const close = findClosing(frame, open, '[', ']') orelse return; + var cursor = open + 1; + while (cursor < close) { + const object_start = indexOfByte(frame, cursor, '{') orelse break; + if (object_start >= close) break; + const object_end = findClosing(frame, object_start, '{', '}') orelse break; + const object = frame[object_start .. object_end + 1]; + try self.recent_projects.append(.{ + .path = try duplicateJsonString(self.allocator, object, "path"), + .name = try duplicateJsonString(self.allocator, object, "name"), + }); + cursor = object_end + 1; + } + // The daemon's recent list is also the authoritative restore/open seed. + // Keep open projects separate so a later project event never evicts older + // graph summaries. + if (std.mem.indexOf(u8, frame, "\"openProjects\"")) |open_key| { + if (indexOfByte(frame, open_key, '[')) |open_projects_start| { + if (findClosing(frame, open_projects_start, '[', ']')) |open_projects_end| { + var open_cursor = open_projects_start + 1; + while (open_cursor < open_projects_end) { + const start = indexOfByte(frame, open_cursor, '{') orelse break; + if (start >= open_projects_end) break; + const end = findClosing(frame, start, '{', '}') orelse break; + const object = frame[start .. end + 1]; + try self.addOpenProject(.{ + .path = try duplicateJsonString(self.allocator, object, "path"), + .name = try duplicateJsonString(self.allocator, object, "name"), + }); + open_cursor = end + 1; + } + } + } + } + } + + fn decodeQuickChats(self: *Model, frame: []const u8, kind: Wire.EventKind) !void { + if (kind == .quick_chats) { + for (self.quick_chats.items) |chat| freeQuickChat(self.allocator, chat); + self.quick_chats.clearRetainingCapacity(); + } + const marker = switch (kind) { + .quick_chats => "\"quickChatsListed\"", + .quick_chat_changed => "\"quickChatChanged\"", + .quick_chat_deleted => "\"quickChatDeleted\"", + .quick_chat_activity => "\"quickChatActivity\"", + else => return, + }; + const start = std.mem.indexOf(u8, frame, marker) orelse return; + if (kind == .quick_chat_deleted) { + const id = Wire.jsonString(frame[start..], "quickChatDeleted") orelse return; + var index: usize = 0; + while (index < self.quick_chats.items.len) : (index += 1) { + if (std.mem.eql(u8, self.quick_chats.items[index].id, id)) { + const removed = self.quick_chats.orderedRemove(index); + freeQuickChat(self.allocator, removed); + return; + } + } + return; + } + const open = indexOfByte(frame, start, if (kind == .quick_chats) '[' else '{') orelse return; + const close = findClosing(frame, open, if (kind == .quick_chats) '[' else '{', if (kind == .quick_chats) ']' else '}') orelse return; + if (kind == .quick_chats) { + var cursor = open + 1; + while (cursor < close) { + const object_start = indexOfByte(frame, cursor, '{') orelse break; + if (object_start >= close) break; + const object_end = findClosing(frame, object_start, '{', '}') orelse break; + try self.upsertQuickChat(frame[object_start .. object_end + 1]); + cursor = object_end + 1; + } + } else if (kind == .quick_chat_activity) { + const object = frame[open .. close + 1]; + const id = Wire.jsonString(object, "id") orelse return; + const activity_start = std.mem.indexOf(u8, object, "\"activity\"") orelse return; + const activity_open = indexOfByte(object, activity_start, '{') orelse return; + const activity_close = findClosing(object, activity_open, '{', '}') orelse return; + const activity_object = object[activity_open .. activity_close + 1]; + const sequence = Wire.jsonNumber(activity_object, "sequence") orelse 0; + const activity = Wire.jsonString(activity_object, "text") orelse ""; + for (self.quick_chats.items) |*chat| { + if (std.mem.eql(u8, chat.id, id) and sequence >= chat.activity_sequence) { + self.allocator.free(chat.activity); + chat.activity = try self.allocator.dupe(u8, activity); + chat.activity_sequence = sequence; + } + } + } else { + try self.upsertQuickChat(frame[open .. close + 1]); + } + } + + fn upsertQuickChat(self: *Model, object: []const u8) !void { + const id = duplicateJsonString(self.allocator, object, "id") catch return; + errdefer self.allocator.free(id); + for (self.quick_chats.items) |*existing| { + if (std.mem.eql(u8, existing.id, id)) { + self.allocator.free(existing.title); + existing.title = try duplicateJsonString(self.allocator, object, "title"); + self.allocator.free(id); + return; + } + } + var chat = QuickChat{ + .id = id, + .title = try duplicateJsonString(self.allocator, object, "title"), + .backend = try duplicateJsonStringOr(self.allocator, object, "backend", "claudeCode"), + }; + if (std.mem.indexOf(u8, object, "\"activity\"")) |activity_key| { + if (indexOfByte(object, activity_key, '{')) |activity_open| { + if (findClosing(object, activity_open, '{', '}')) |activity_close| { + const activity_object = object[activity_open .. activity_close + 1]; + chat.activity_sequence = Wire.jsonNumber(activity_object, "sequence") orelse 0; + chat.activity = try self.allocator.dupe( + u8, + Wire.jsonString(activity_object, "text") orelse "", + ); + } + } + } + try self.quick_chats.append(chat); + } + + fn decodeGraph(self: *Model, frame: []const u8) !void { + const graph_start = std.mem.indexOf(u8, frame, "\"graphChanged\"") orelse return; + const object_start = indexOfByte(frame, graph_start, '{') orelse return; + const object_end = findClosing(frame, object_start, '{', '}') orelse return error.MalformedGraph; + const graph_json = frame[object_start .. object_end + 1]; + var graph = Graph{ + .project = .{ + .path = try duplicateJsonString(self.allocator, graph_json, "path"), + .name = try duplicateJsonString(self.allocator, graph_json, "name"), + }, + .nodes = std.array_list.Managed(Node).init(self.allocator), + .edges = std.array_list.Managed(Edge).init(self.allocator), + }; + errdefer freeGraph(self.allocator, &graph); + + if (std.mem.indexOf(u8, graph_json, "\"nodes\"")) |nodes_key| { + if (indexOfByte(graph_json, nodes_key, '[')) |nodes_open| { + if (findClosing(graph_json, nodes_open, '[', ']')) |nodes_close| { + try decodeNodes(self.allocator, graph_json[nodes_open + 1 .. nodes_close], &graph.nodes); + } + } + } + if (std.mem.indexOf(u8, graph_json, "\"edges\"")) |edges_key| { + if (indexOfByte(graph_json, edges_key, '[')) |edges_open| { + if (findClosing(graph_json, edges_open, '[', ']')) |edges_close| { + try decodeEdges(self.allocator, graph_json[edges_open + 1 .. edges_close], &graph.edges); + } + } + } + const was_selected = if (self.selected_project_path) |path| + std.mem.eql(u8, path, graph.project.path) + else self.graph == null; + const prior_node_id: ?[]const u8 = if (was_selected) self.selected_node_id else null; + self.recordActivity(graph); + try self.upsertSummary(&graph); + self.markGraphSeen(graph.project.path); + self.rebuildAttention(); + try self.addOpenProject(.{ + .path = try self.allocator.dupe(u8, graph.project.path), + .name = try self.allocator.dupe(u8, graph.project.name), + }); + if (self.graph) |*old| freeGraph(self.allocator, old); + self.graph = graph; + if (self.selected_project_path == null) { + self.selected_project_path = try self.allocator.dupe(u8, graph.project.path); + } + if (was_selected and graph.nodes.items.len == 0) { + self.selected_index = null; + self.freeSelectedNodeID(); + } else if (was_selected) { + self.selected_index = 0; + if (prior_node_id) |node_id| { + for (graph.nodes.items, 0..) |node, index| { + if (std.mem.eql(u8, node.id, node_id)) { + self.selected_index = index; + self.replaceSelectedNodeID(node.id); + break; + } + } + } else if (graph.nodes.items.len != 0) { + self.replaceSelectedNodeID(graph.nodes.items[0].id); + } + } + self.syncLegacyGraph(); + } + + fn syncLegacyGraph(self: *Model) void { + if (self.graph) |*old| { + freeGraph(self.allocator, old); + self.graph = null; + } + const path = self.selected_project_path orelse return; + const summary = self.graphFor(path) orelse return; + const project_path = self.allocator.dupe(u8, summary.project.path) catch return; + const project_name = self.allocator.dupe(u8, summary.project.name) catch { + self.allocator.free(project_path); + return; + }; + var graph = Graph{ + .project = .{ + .path = project_path, + .name = project_name, + }, + .nodes = std.array_list.Managed(Node).init(self.allocator), + .edges = std.array_list.Managed(Edge).init(self.allocator), + }; + for (summary.nodes.items) |node| { + const copy = cloneNode(self.allocator, node) catch { + freeGraph(self.allocator, &graph); + return; + }; + graph.nodes.append(copy) catch { + freeNode(self.allocator, copy); + freeGraph(self.allocator, &graph); + return; + }; + } + for (summary.edges.items) |edge| { + const copy = cloneEdge(self.allocator, edge) catch { + freeGraph(self.allocator, &graph); + return; + }; + graph.edges.append(copy) catch { + freeEdge(self.allocator, copy); + freeGraph(self.allocator, &graph); + return; + }; + } + self.graph = graph; + self.applyOpenComposite(); + } + + fn applyOpenComposite(self: *Model) void { + const node_id = self.open_composite_id orelse return; + const top = self.graph orelse return; + const index = findNodeIndexByID(top.nodes.items, node_id) orelse { + self.clearOpenComposite(); + return; + }; + const node = top.nodes.items[index]; + if (self.allocator.dupe(u8, node.title)) |title| { + if (self.open_composite_title) |old| self.allocator.free(old); + self.open_composite_title = title; + } else |_| {} + const nested = decodeSubgraph(self.allocator, top.project, node.subgraph_json) catch { + self.clearOpenComposite(); + return; + }; + if (self.graph) |*old| freeGraph(self.allocator, old); + self.graph = nested; + if (nested.nodes.items.len == 0) { + self.selected_index = null; + self.freeSelectedNodeID(); + } else { + const retained = if (self.selected_node_id) |id| + findNodeIndexByID(nested.nodes.items, id) + else + null; + self.selected_index = retained orelse 0; + self.replaceSelectedNodeID(nested.nodes.items[self.selected_index.?].id); + } + } + + fn addOpenProject(self: *Model, project: Project) !void { + for (self.open_projects.items) |existing| { + if (std.mem.eql(u8, existing.path, project.path)) { + freeProject(self.allocator, project); + return; + } + } + try self.open_projects.append(project); + } + + fn upsertSummary(self: *Model, graph: *const Graph) !void { + for (self.graphs.items) |*summary| { + if (!std.mem.eql(u8, summary.project.path, graph.project.path)) continue; + for (summary.nodes.items) |node| freeNode(self.allocator, node); + for (summary.edges.items) |edge| freeEdge(self.allocator, edge); + summary.nodes.clearRetainingCapacity(); + summary.edges.clearRetainingCapacity(); + for (graph.nodes.items) |node| try summary.nodes.append(try cloneNode(self.allocator, node)); + for (graph.edges.items) |edge| try summary.edges.append(try cloneEdge(self.allocator, edge)); + return; + } + var summary = GraphSummary{ + .project = .{ + .path = try self.allocator.dupe(u8, graph.project.path), + .name = try self.allocator.dupe(u8, graph.project.name), + }, + .nodes = std.array_list.Managed(Node).init(self.allocator), + .edges = std.array_list.Managed(Edge).init(self.allocator), + }; + errdefer summary.deinit(self.allocator); + for (graph.nodes.items) |node| try summary.nodes.append(try cloneNode(self.allocator, node)); + for (graph.edges.items) |edge| try summary.edges.append(try cloneEdge(self.allocator, edge)); + try self.graphs.append(summary); + } + + fn markGraphSeen(self: *Model, path: []const u8) void { + for (self.graph_generations.items) |*entry| { + if (std.mem.eql(u8, entry.project_path, path)) { + entry.generation = self.restore_generation; + return; + } + } + self.graph_generations.append(.{ + .project_path = self.allocator.dupe(u8, path) catch return, + .generation = self.restore_generation, + }) catch {}; + } + + fn wasGraphSeen(self: *const Model, path: []const u8) bool { + for (self.graph_generations.items) |entry| { + if (std.mem.eql(u8, entry.project_path, path)) return entry.generation == self.restore_generation; + } + return false; + } + + fn replaceAttention(self: *Model, graph: *const Graph) void { + _ = graph; + self.rebuildAttention(); + } + + fn rebuildAttention(self: *Model) void { + for (self.attention.items) |node| freeNode(self.allocator, node); + self.attention.clearRetainingCapacity(); + for (self.attention_entries.items) |entry| freeAttentionEntry(self.allocator, entry); + self.attention_entries.clearRetainingCapacity(); + for (self.graphs.items) |summary| { + for (summary.nodes.items) |node| { + if (!needsAttention(node) and !(std.mem.eql(u8, node.state, "blocked") and isStrandedSummary(&summary, node.id))) continue; + const node_copy = cloneNode(self.allocator, node) catch continue; + const entry = AttentionEntry{ .project_path = self.allocator.dupe(u8, summary.project.path) catch { freeNode(self.allocator, node_copy); continue; }, .node = node_copy }; + self.attention_entries.append(entry) catch { freeAttentionEntry(self.allocator, entry); continue; }; + const compat = cloneNode(self.allocator, node) catch continue; + self.attention.append(compat) catch freeNode(self.allocator, compat); + } + } + std.sort.heap(AttentionEntry, self.attention_entries.items, {}, compareAttentionEntry); + std.sort.heap(Node, self.attention.items, {}, compareAttentionNode); + } + + fn recordActivity(self: *Model, next: Graph) void { + const previous = self.graphFor(next.project.path) orelse return; + for (next.nodes.items) |node| { + const old = findNode(previous.nodes.items, node.id) orelse continue; + if (std.mem.eql(u8, old.state, node.state)) continue; + const title = self.allocator.dupe(u8, node.title) catch continue; + const state = self.allocator.dupe(u8, node.state) catch { + self.allocator.free(title); + continue; + }; + const event = ActivityEvent{ .title = title, .state = state }; + self.activity.insert(0, event) catch { + self.allocator.free(event.title); + self.allocator.free(event.state); + continue; + }; + if (self.activity.items.len > 32) { + const removed = self.activity.pop() orelse continue; + self.allocator.free(removed.title); + self.allocator.free(removed.state); + } + } + } +}; + +fn findNode(nodes: []const Node, id: []const u8) ?Node { + for (nodes) |node| if (std.mem.eql(u8, node.id, id)) return node; + return null; +} + +pub fn findNodeIndexByID(nodes: []const Node, id: []const u8) ?usize { + for (nodes, 0..) |node, index| if (std.mem.eql(u8, node.id, id)) return index; + return null; +} + +pub fn findEdgeIndexByID(edges: []const Edge, id: []const u8) ?usize { + for (edges, 0..) |edge, index| if (std.mem.eql(u8, edge.id, id)) return index; + return null; +} + +fn needsAttention(node: Node) bool { + return std.mem.eql(u8, node.state, "failed") or + std.mem.eql(u8, node.state, "stalled") or + std.mem.eql(u8, node.presence, "awaitingInput"); +} + +fn attentionRank(node: Node) u8 { + if (std.mem.eql(u8, node.state, "failed")) return 0; + if (std.mem.eql(u8, node.state, "stalled")) return 1; + if (std.mem.eql(u8, node.presence, "awaitingInput")) return 2; + return 3; +} + +fn isStranded(graph: *const Graph, node_id: []const u8) bool { + var has_blocking_edge = false; + for (graph.edges.items) |edge| { + if (!std.mem.eql(u8, edge.to, node_id) or !edge.blocks_target or edge.fired) continue; + has_blocking_edge = true; + const source = findNode(graph.nodes.items, edge.from) orelse return false; + if (!isResolved(source.state)) return false; + } + + return has_blocking_edge; +} + +fn isStrandedSummary(graph: *const GraphSummary, node_id: []const u8) bool { + var has_blocking_edge = false; + for (graph.edges.items) |edge| { + if (!std.mem.eql(u8, edge.to, node_id) or !edge.blocks_target or edge.fired) continue; + has_blocking_edge = true; + const source = findNode(graph.nodes.items, edge.from) orelse return false; + if (!isResolved(source.state)) return false; + } + return has_blocking_edge; +} + +fn compareAttentionEntry(_: void, a: AttentionEntry, b: AttentionEntry) bool { + return attentionRank(a.node) < attentionRank(b.node); +} + +fn compareAttentionNode(_: void, a: Node, b: Node) bool { + return attentionRank(a) < attentionRank(b); +} + +fn isResolved(state: []const u8) bool { + return std.mem.eql(u8, state, "succeeded") or + std.mem.eql(u8, state, "failed") or + std.mem.eql(u8, state, "stalled") or + std.mem.eql(u8, state, "stopped"); +} + +fn cloneNode(allocator: std.mem.Allocator, node: Node) !Node { + return .{ + .id = try allocator.dupe(u8, node.id), + .title = try allocator.dupe(u8, node.title), + .loop_type = try allocator.dupe(u8, node.loop_type), + .state = try allocator.dupe(u8, node.state), + .activity = try allocator.dupe(u8, node.activity), + .presence = try allocator.dupe(u8, node.presence), + .pilot_state = try allocator.dupe(u8, node.pilot_state), + .goal_summary = try allocator.dupe(u8, node.goal_summary), + .goal_predicate = try allocator.dupe(u8, node.goal_predicate), + .metric_command = try allocator.dupe(u8, node.metric_command), + .metric_direction = try allocator.dupe(u8, node.metric_direction), + .trigger_prompt = try allocator.dupe(u8, node.trigger_prompt), + .check_description = try allocator.dupe(u8, node.check_description), + .model_tier = try allocator.dupe(u8, node.model_tier), + .poll_interval_seconds = node.poll_interval_seconds, + .stall_after_seconds = node.stall_after_seconds, + .worktree_path = try allocator.dupe(u8, node.worktree_path), + .worktree_branch = try allocator.dupe(u8, node.worktree_branch), + .subgraph_json = try allocator.dupe(u8, node.subgraph_json), + }; +} + +fn cloneEdge(allocator: std.mem.Allocator, edge: Edge) !Edge { + return .{ + .id = try allocator.dupe(u8, edge.id), + .from = try allocator.dupe(u8, edge.from), + .to = try allocator.dupe(u8, edge.to), + .kind = try allocator.dupe(u8, edge.kind), + .condition = try allocator.dupe(u8, edge.condition), + .blocks_target = edge.blocks_target, + .fired = edge.fired, + .fire_count = edge.fire_count, + }; +} + +fn freeEdge(allocator: std.mem.Allocator, edge: Edge) void { + allocator.free(edge.id); + allocator.free(edge.from); + allocator.free(edge.to); + allocator.free(edge.kind); + allocator.free(edge.condition); +} + +fn freeAttentionEntry(allocator: std.mem.Allocator, entry: AttentionEntry) void { + allocator.free(entry.project_path); + freeNode(allocator, entry.node); +} + +fn decodeNodes( + allocator: std.mem.Allocator, + bytes: []const u8, + nodes: *std.array_list.Managed(Node), +) !void { + var cursor: usize = 0; + while (cursor < bytes.len) { + const start = indexOfByte(bytes, cursor, '{') orelse break; + const end = findClosing(bytes, start, '{', '}') orelse break; + const object = bytes[start .. end + 1]; + const scalar_object = try withoutJsonObjectField(allocator, object, "subGraph"); + defer allocator.free(scalar_object); + try nodes.append(.{ + .id = try duplicateJsonString(allocator, scalar_object, "id"), + .title = try duplicateJsonStringOr(allocator, scalar_object, "title", "Untitled"), + .loop_type = try duplicateJsonStringOr(allocator, scalar_object, "loopType", "turnBased"), + .state = try duplicateJsonStringOr(allocator, scalar_object, "state", "idle"), + .activity = try duplicateJsonStringOr(allocator, scalar_object, "activity", ""), + .presence = try duplicatePresence(allocator, scalar_object), + .pilot_state = try duplicateJsonStringOr(allocator, scalar_object, "pilotState", "notPiloted"), + .goal_summary = try duplicateJsonStringOr(allocator, scalar_object, "summary", ""), + .goal_predicate = try duplicateJsonStringOr(allocator, scalar_object, "predicate", ""), + .metric_command = try duplicateJsonStringOr(allocator, scalar_object, "metricCommand", ""), + .metric_direction = try duplicateJsonStringOr(allocator, scalar_object, "metricDirection", ""), + .trigger_prompt = try duplicateJsonStringOr(allocator, scalar_object, "triggerPrompt", ""), + .check_description = try duplicateJsonStringOr(allocator, scalar_object, "checkDescription", ""), + .model_tier = try duplicateJsonStringOr(allocator, scalar_object, "modelTier", ""), + .poll_interval_seconds = jsonFloat(scalar_object, "pollIntervalSeconds"), + .stall_after_seconds = jsonFloat(scalar_object, "stallAfterSeconds"), + .worktree_path = try duplicateWorktreePath(allocator, scalar_object), + .worktree_branch = try duplicateWorktreeBranch(allocator, scalar_object), + .subgraph_json = try duplicateJsonObjectOrEmpty(allocator, object, "subGraph"), + }); + cursor = end + 1; + } +} + +fn decodeEdges( + allocator: std.mem.Allocator, + bytes: []const u8, + edges: *std.array_list.Managed(Edge), +) !void { + var cursor: usize = 0; + while (cursor < bytes.len) { + const start = indexOfByte(bytes, cursor, '{') orelse break; + const end = findClosing(bytes, start, '{', '}') orelse break; + const object = bytes[start .. end + 1]; + try edges.append(.{ + .id = try duplicateJsonStringOr(allocator, object, "id", ""), + .from = try duplicateJsonString(allocator, object, "from"), + .to = try duplicateJsonString(allocator, object, "to"), + .kind = try duplicateJsonStringOr(allocator, object, "kind", "handoff"), + .condition = try duplicateJsonStringOr(allocator, object, "condition", "always"), + .blocks_target = !std.mem.eql(u8, Wire.jsonString(object, "kind") orelse "", "message"), + .fired = jsonBool(object, "fired") orelse false, + .fire_count = jsonNumber(object, "fireCount") orelse 0, + }); + cursor = end + 1; + } +} + +fn jsonBool(object: []const u8, key: []const u8) ?bool { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return null; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return null; + const value = object[start + needle.len ..]; + if (std.mem.startsWith(u8, value, "true")) return true; + if (std.mem.startsWith(u8, value, "false")) return false; + return null; +} + +fn jsonNumber(object: []const u8, key: []const u8) ?u32 { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return null; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return null; + const value = std.mem.trimLeft(u8, object[start + needle.len ..], " "); + var end: usize = 0; + while (end < value.len and value[end] >= '0' and value[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return std.fmt.parseInt(u32, value[0..end], 10) catch null; +} + +fn jsonFloat(object: []const u8, key: []const u8) ?f64 { + const needle = std.fmt.allocPrint(std.heap.page_allocator, "\"{s}\":", .{key}) catch return null; + defer std.heap.page_allocator.free(needle); + const start = std.mem.indexOf(u8, object, needle) orelse return null; + const value = std.mem.trimLeft(u8, object[start + needle.len ..], " "); + var end: usize = 0; + while (end < value.len and (std.ascii.isDigit(value[end]) or value[end] == '.' or value[end] == '-' or value[end] == '+' or value[end] == 'e' or value[end] == 'E')) : (end += 1) {} + if (end == 0) return null; + return std.fmt.parseFloat(f64, value[0..end]) catch null; +} + +fn duplicateJsonString(allocator: std.mem.Allocator, object: []const u8, key: []const u8) ![]u8 { + return Wire.decodeJsonString(allocator, Wire.jsonString(object, key) orelse ""); +} + +fn duplicateJsonObjectOrEmpty( + allocator: std.mem.Allocator, + object: []const u8, + key: []const u8, +) ![]u8 { + const needle = try std.fmt.allocPrint(allocator, "\"{s}\":", .{key}); + defer allocator.free(needle); + const key_start = std.mem.indexOf(u8, object, needle) orelse return allocator.dupe(u8, ""); + const value = std.mem.trimLeft(u8, object[key_start + needle.len ..], " \t\r\n"); + if (value.len == 0 or value[0] != '{') return allocator.dupe(u8, ""); + const end = findClosing(value, 0, '{', '}') orelse return allocator.dupe(u8, ""); + return allocator.dupe(u8, value[0 .. end + 1]); +} + +fn withoutJsonObjectField( + allocator: std.mem.Allocator, + object: []const u8, + key: []const u8, +) ![]u8 { + const needle = try std.fmt.allocPrint(allocator, "\"{s}\":", .{key}); + defer allocator.free(needle); + const key_start = std.mem.indexOf(u8, object, needle) orelse return allocator.dupe(u8, object); + const value_start = key_start + needle.len; + const value = std.mem.trimLeft(u8, object[value_start..], " \t\r\n"); + if (value.len == 0 or value[0] != '{') return allocator.dupe(u8, object); + const value_offset = @intFromPtr(value.ptr) - @intFromPtr(object.ptr); + const end = findClosing(object, value_offset, '{', '}') orelse return allocator.dupe(u8, object); + return std.fmt.allocPrint(allocator, "{s}{s}", .{ object[0..key_start], object[end + 1 ..] }); +} + +pub fn subgraphNodeCount(subgraph_json: []const u8) usize { + const nodes_start = std.mem.indexOf(u8, subgraph_json, "\"nodes\":") orelse return 0; + const value = std.mem.trimLeft(u8, subgraph_json[nodes_start + "\"nodes\":".len ..], " \t\r\n"); + if (value.len == 0 or value[0] != '[') return 0; + const end = findClosing(value, 0, '[', ']') orelse return 0; + var count: usize = 0; + var depth: usize = 0; + var in_string = false; + var escaped = false; + for (value[1..end]) |byte| { + if (in_string) { + if (escaped) escaped = false else if (byte == '\\') escaped = true else if (byte == '"') in_string = false; + continue; + } + if (byte == '"') { + in_string = true; + } else if (byte == '{') { + if (depth == 0) count += 1; + depth += 1; + } else if (byte == '}' and depth != 0) { + depth -= 1; + } + } + return count; +} + +pub fn decodeSubgraph( + allocator: std.mem.Allocator, + parent_project: Project, + subgraph_json: []const u8, +) !Graph { + var graph = Graph{ + .project = .{ + .path = try allocator.dupe(u8, parent_project.path), + .name = try allocator.dupe(u8, parent_project.name), + }, + .nodes = std.array_list.Managed(Node).init(allocator), + .edges = std.array_list.Managed(Edge).init(allocator), + }; + errdefer freeGraph(allocator, &graph); + if (std.mem.indexOf(u8, subgraph_json, "\"nodes\"")) |nodes_key| { + if (indexOfByte(subgraph_json, nodes_key, '[')) |nodes_open| { + const nodes_close = findClosing(subgraph_json, nodes_open, '[', ']') orelse + return error.MalformedSubgraph; + try decodeNodes( + allocator, + subgraph_json[nodes_open + 1 .. nodes_close], + &graph.nodes, + ); + } + } + if (std.mem.indexOf(u8, subgraph_json, "\"edges\"")) |edges_key| { + if (indexOfByte(subgraph_json, edges_key, '[')) |edges_open| { + const edges_close = findClosing(subgraph_json, edges_open, '[', ']') orelse + return error.MalformedSubgraph; + try decodeEdges( + allocator, + subgraph_json[edges_open + 1 .. edges_close], + &graph.edges, + ); + } + } + return graph; +} + +fn indexOfByte(bytes: []const u8, start: usize, byte: u8) ?usize { + if (start >= bytes.len) return null; + const offset = std.mem.indexOf(u8, bytes[start..], &[_]u8{byte}) orelse return null; + return start + offset; +} + +fn duplicateJsonStringOr( + allocator: std.mem.Allocator, + object: []const u8, + key: []const u8, + fallback: []const u8, +) ![]u8 { + return Wire.decodeJsonString(allocator, Wire.jsonString(object, key) orelse fallback); +} + +fn duplicatePresence(allocator: std.mem.Allocator, object: []const u8) ![]u8 { + if (Wire.jsonString(object, "presence")) |value| { + return Wire.decodeJsonString(allocator, value); + } + + const key = std.mem.indexOf(u8, object, "\"presence\"") orelse + return allocator.dupe(u8, ""); + const open = indexOfByte(object, key, '{') orelse return allocator.dupe(u8, ""); + const close = findClosing(object, open, '{', '}') orelse return allocator.dupe(u8, ""); + const reading = object[open .. close + 1]; + return duplicateJsonStringOr(allocator, reading, "presence", ""); +} + +fn duplicateWorktreePath(allocator: std.mem.Allocator, object: []const u8) ![]u8 { + const key = std.mem.indexOf(u8, object, "\"worktreeBinding\"") orelse + return allocator.dupe(u8, ""); + const open = indexOfByte(object, key, '{') orelse return allocator.dupe(u8, ""); + const close = findClosing(object, open, '{', '}') orelse return allocator.dupe(u8, ""); + const binding = object[open .. close + 1]; + return duplicateJsonStringOr(allocator, binding, "path", Wire.jsonString(binding, "worktreePath") orelse ""); +} + +fn duplicateWorktreeBranch(allocator: std.mem.Allocator, object: []const u8) ![]u8 { + const key = std.mem.indexOf(u8, object, "\"worktreeBinding\"") orelse return allocator.dupe(u8, ""); + const open = indexOfByte(object, key, '{') orelse return allocator.dupe(u8, ""); + const close = findClosing(object, open, '{', '}') orelse return allocator.dupe(u8, ""); + return duplicateJsonStringOr(allocator, object[open .. close + 1], "branch", ""); +} + +fn findClosing(bytes: []const u8, start: usize, open: u8, close: u8) ?usize { + var depth: usize = 0; + var quoted = false; + var escaped = false; + var index = start; + while (index < bytes.len) : (index += 1) { + const byte = bytes[index]; + if (quoted) { + if (escaped) escaped = false else if (byte == '\\') escaped = true else if (byte == '"') quoted = false; + continue; + } + if (byte == '"') { + quoted = true; + } else if (byte == open) { + depth += 1; + } else if (byte == close) { + depth -= 1; + if (depth == 0) return index; + } + } + return null; +} + +fn freeProject(allocator: std.mem.Allocator, project: Project) void { + allocator.free(project.path); + allocator.free(project.name); +} + +fn freeNode(allocator: std.mem.Allocator, node: Node) void { + allocator.free(node.id); + allocator.free(node.title); + allocator.free(node.loop_type); + allocator.free(node.state); + allocator.free(node.activity); + allocator.free(node.presence); + allocator.free(node.pilot_state); + allocator.free(node.goal_summary); + allocator.free(node.goal_predicate); + allocator.free(node.metric_command); + allocator.free(node.metric_direction); + allocator.free(node.trigger_prompt); + allocator.free(node.check_description); + allocator.free(node.model_tier); + allocator.free(node.worktree_path); + allocator.free(node.worktree_branch); + allocator.free(node.subgraph_json); +} + +fn freeQuickChat(allocator: std.mem.Allocator, chat: QuickChat) void { + allocator.free(chat.id); + allocator.free(chat.title); + allocator.free(chat.backend); + if (chat.activity.len != 0) allocator.free(chat.activity); +} + +fn freeGraph(allocator: std.mem.Allocator, graph: *Graph) void { + freeProject(allocator, graph.project); + for (graph.nodes.items) |node| freeNode(allocator, node); + for (graph.edges.items) |edge| { + if (edge.id.len != 0) allocator.free(edge.id); + allocator.free(edge.from); + allocator.free(edge.to); + allocator.free(edge.kind); + allocator.free(edge.condition); + } + graph.nodes.deinit(); + graph.edges.deinit(); +} + +test "graph snapshots decode escaped project data and presence" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":7,"event":{"graphChanged":{"id":"graph","project":{"path":"C:\\work\\graph","name":"Visual \u2603"},"nodes":[{"id":"node","title":"Node \"A\"","loopType":"turnBased","state":"running","activity":"editing","presence":{"presence":"busy","confidence":"reported"}}],"edges":[]}}} + ; + try std.testing.expectEqual(Wire.EventKind.graph_changed, try model.updateFromFrame(frame)); + const graph = model.graph orelse return error.TestExpectedGraph; + try std.testing.expectEqualStrings("C:\\work\\graph", graph.project.path); + try std.testing.expectEqualStrings("Visual ☃", graph.project.name); + try std.testing.expectEqual(@as(usize, 1), graph.nodes.items.len); + try std.testing.expectEqualStrings("Node \"A\"", graph.nodes.items[0].title); + try std.testing.expectEqualStrings("busy", graph.nodes.items[0].presence); +} + +test "stub graph snapshot decodes two actionable nodes" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"stub-graph","project":{"path":"graphcode://stub/project","name":"Stub project"},"nodes":[{"id":"11111111-1111-4111-8111-111111111111","title":"Stub node A","loopType":"turnBased","state":"running","activity":"stub","presence":{"presence":"busy","confidence":"reported"}},{"id":"22222222-2222-4222-8222-222222222222","title":"Stub node B","loopType":"turnBased","state":"idle","activity":"stub","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}} + ; + try std.testing.expectEqual(Wire.EventKind.graph_changed, try model.updateFromFrame(frame)); + const graph = model.graph orelse return error.TestExpectedGraph; + try std.testing.expectEqualStrings("graphcode://stub/project", graph.project.path); + try std.testing.expectEqual(@as(usize, 2), graph.nodes.items.len); + try std.testing.expectEqualStrings( + "11111111-1111-4111-8111-111111111111", + graph.nodes.items[0].id, + ); + try std.testing.expectEqualStrings("busy", graph.nodes.items[0].presence); + try std.testing.expectEqualStrings("idle", graph.nodes.items[1].presence); +} + +test "reordered graph fixture preserves nonsequential edge IDs" { + const allocator = std.testing.allocator; + const frame = try std.fs.cwd().readFileAlloc( + allocator, + "fixtures/daemon-v2-graph-reordered-edges.json", + 64 * 1024, + ); + defer allocator.free(frame); + var model = Model.init(allocator); + defer model.deinit(); + try std.testing.expectEqual(Wire.EventKind.graph_changed, try model.updateFromFrame(frame)); + const graph = model.graph orelse return error.TestExpectedGraph; + try std.testing.expectEqualStrings("node-z", graph.nodes.items[0].id); + try std.testing.expectEqualStrings("node-a", graph.nodes.items[1].id); + try std.testing.expectEqualStrings("node-a", graph.edges.items[0].from); + try std.testing.expectEqualStrings("node-q", graph.edges.items[0].to); + try std.testing.expectEqualStrings("node-z", graph.edges.items[1].from); + try std.testing.expectEqualStrings("node-a", graph.edges.items[1].to); +} + +test "quick chat fixture preserves stable identity and activity ordering" { + const allocator = std.testing.allocator; + const frame = try std.fs.cwd().readFileAlloc( + allocator, + "fixtures/daemon-v2-quick-chats.json", + 64 * 1024, + ); + defer allocator.free(frame); + var model = Model.init(allocator); + defer model.deinit(); + try std.testing.expectEqual(Wire.EventKind.quick_chats, try model.updateFromFrame(frame)); + try std.testing.expectEqual(@as(usize, 2), model.quick_chats.items.len); + try std.testing.expectEqualStrings("Scratch", model.quick_chats.items[0].title); + try std.testing.expectEqual(@as(u64, 2), model.quick_chats.items[0].activity_sequence); +} + +test "project identity derives remote and global from Codable paths" { + const remote = Project{ .path = @constCast("ssh://build/graph"), .name = @constCast("Remote") }; + const global = Project{ .path = @constCast("graphcode://global"), .name = @constCast("Graph") }; + const local = Project{ .path = @constCast("C:\\work\\graph"), .name = @constCast("Local") }; + try std.testing.expect(remote.isRemote()); + try std.testing.expect(!remote.isGlobal()); + try std.testing.expect(global.isGlobal()); + try std.testing.expect(!global.isRemote()); + try std.testing.expect(!local.isRemote()); + try std.testing.expect(!local.isGlobal()); +} + +test "multi-project fixture retains both summaries and selection identity" { + const allocator = std.testing.allocator; + var model = Model.init(allocator); + defer model.deinit(); + const local = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project.json", 64 * 1024); + defer allocator.free(local); + const remote = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project-remote.json", 64 * 1024); + defer allocator.free(remote); + _ = try model.updateFromFrame(local); + _ = try model.updateFromFrame(remote); + try std.testing.expectEqual(@as(usize, 2), model.graphs.items.len); + try std.testing.expectEqual(@as(usize, 2), model.open_projects.items.len); + try std.testing.expectEqualStrings("C:\\work\\local", model.selected_project_path.?); + try std.testing.expect(model.graphFor("C:\\work\\local") != null); + try std.testing.expect(model.selectProject("C:\\work\\local")); + try std.testing.expectEqualStrings("C:\\work\\local", model.selected_project_path.?); +} + +test "current graph and selection operations resolve the selected project" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"a","name":"A"},"nodes":[{"id":"a1","title":"A1","state":"running"}],"edges":[{"from":"a1","to":"a1","kind":"message"}]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"b","name":"B"},"nodes":[{"id":"b1","title":"B1","state":"running"},{"id":"b2","title":"B2","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(first); + _ = try model.updateFromFrame(second); + try std.testing.expectEqual(@as(usize, 1), model.graphFor("a").?.edges.items.len); + try std.testing.expectEqualStrings("a", model.currentGraph().?.project.path); + model.selectNext(); + try std.testing.expectEqualStrings("A1", model.selected().?.title); + try std.testing.expect(model.selectProject("b")); + try std.testing.expectEqualStrings("b", model.currentGraph().?.project.path); + model.selectNext(); + try std.testing.expectEqualStrings("B2", model.selected().?.title); +} + +test "selecting B resynchronizes the active snapshot and current graph" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const a = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"A"},"nodes":[{"id":"a1","title":"A1","state":"running"}],"edges":[]}}} + ; + const b = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"B"},"nodes":[{"id":"b1","title":"B1","state":"failed"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(a); + _ = try model.updateFromFrame(b); + try std.testing.expect(model.selectProject("B")); + try std.testing.expectEqualStrings("B", model.currentGraph().?.project.path); + try std.testing.expectEqualStrings("B", model.graph.?.project.path); + try std.testing.expectEqualStrings("B1", model.graph.?.nodes.items[0].title); +} + +test "restore generation removes unreplayed graphs and preserves valid selection" { + const allocator = std.testing.allocator; + var model = Model.init(allocator); + defer model.deinit(); + const local = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project.json", 64 * 1024); + defer allocator.free(local); + const remote = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project-remote.json", 64 * 1024); + defer allocator.free(remote); + _ = try model.updateFromFrame(local); + _ = try model.updateFromFrame(remote); + try std.testing.expect(model.selectProject("ssh://build/remote")); + model.beginRestore(); + _ = try model.updateFromFrame(remote); + model.markRestored(); + try std.testing.expectEqual(@as(usize, 1), model.graphs.items.len); + try std.testing.expectEqualStrings("ssh://build/remote", model.selected_project_path.?); + try std.testing.expectEqual(RestoreState.restored, model.restore_state); +} + +test "restore fallback resynchronizes graph after removing selected A" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const a = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"A"},"nodes":[{"id":"a1","title":"A1","state":"running"}],"edges":[]}}} + ; + const b = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"B"},"nodes":[{"id":"b1","title":"B1","state":"failed"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(a); + _ = try model.updateFromFrame(b); + try std.testing.expect(model.selectProject("A")); + model.beginRestore(); + _ = try model.updateFromFrame(b); + model.markRestored(); + try std.testing.expectEqualStrings("B", model.selected_project_path.?); + try std.testing.expectEqualStrings("B", model.currentGraph().?.project.path); + try std.testing.expectEqualStrings("B", model.graph.?.project.path); + try std.testing.expectEqualStrings("B1", model.graph.?.nodes.items[0].title); +} + +test "attention aggregate keeps project and node identity" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const local = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"a","name":"A"},"nodes":[{"id":"a1","title":"Local failure","state":"failed"}],"edges":[]}}} + ; + const remote = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"b","name":"B"},"nodes":[{"id":"b1","title":"Remote question","state":"running","presence":{"presence":"awaitingInput"}}],"edges":[]}}} + ; + _ = try model.updateFromFrame(local); + _ = try model.updateFromFrame(remote); + try std.testing.expectEqual(@as(usize, 2), model.attention_entries.items.len); + try std.testing.expectEqualStrings("a", model.attention_entries.items[0].project_path); + try std.testing.expectEqualStrings("a1", model.attention_entries.items[0].node.id); + try std.testing.expectEqualStrings("b", model.attention_entries.items[1].project_path); + model.selectNextAttention(); + try std.testing.expectEqualStrings("b", model.selected_project_path.?); +} + +test "close forget and delete have distinct graph lifecycle semantics" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\work\\graph"), + .name = try std.testing.allocator.dupe(u8, "Graph"), + }); + try std.testing.expect(model.applyLifecycle(.close, "C:\\work\\graph")); + try std.testing.expectEqual(@as(usize, 0), model.graphs.items.len); + try std.testing.expectEqual(@as(usize, 1), model.recent_projects.items.len); + try std.testing.expect(model.applyLifecycle(.forget, "C:\\work\\graph")); + try std.testing.expectEqual(@as(usize, 0), model.recent_projects.items.len); +} + +test "lifecycle callback receives an owned path before graph storage is freed" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"owned-path","name":"Graph"},"nodes":[],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + var probe = LifecycleProbe{}; + model.setLifecycleCallback(&probe, lifecycleProbeCallback); + try std.testing.expect(model.applyLifecycle(.close, model.graphs.items[0].project.path)); + try std.testing.expect(probe.called); + try std.testing.expectEqualStrings("owned-path", probe.path[0..probe.path_len]); +} + +test "closing selected A preserves project B and its active snapshot" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const a = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"A"},"nodes":[{"id":"a1","title":"A1","state":"running"}],"edges":[]}}} + ; + const b = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"B"},"nodes":[{"id":"b1","title":"B1","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(a); + _ = try model.updateFromFrame(b); + try std.testing.expect(model.selectProject("A")); + try std.testing.expect(model.applyLifecycle(.close, "A")); + try std.testing.expectEqualStrings("B", model.selected_project_path.?); + try std.testing.expectEqualStrings("B", model.currentGraph().?.project.path); + try std.testing.expectEqualStrings("B", model.graph.?.project.path); +} + +test "closing snapshot B while selected A resynchronizes the active graph to A" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const a = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"A"},"nodes":[{"id":"a1","title":"A1","state":"running"}],"edges":[]}}} + ; + const b = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"B"},"nodes":[{"id":"b1","title":"B1","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(a); + _ = try model.updateFromFrame(b); + try std.testing.expect(model.selectProject("A")); + try std.testing.expectEqualStrings("A", model.currentGraph().?.project.path); + try std.testing.expect(model.applyLifecycle(.close, "B")); + try std.testing.expectEqualStrings("A", model.selected_project_path.?); + try std.testing.expectEqualStrings("A", model.currentGraph().?.project.path); + try std.testing.expectEqualStrings("A", model.graph.?.project.path); +} + +test "restore remains restoring until markRestored" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"restore-path","name":"Graph"},"nodes":[],"edges":[]}}} + ; + model.beginRestore(); + _ = try model.updateFromFrame(frame); + try std.testing.expectEqual(RestoreState.restoring, model.restore_state); + model.markRestored(); + try std.testing.expectEqual(RestoreState.restored, model.restore_state); +} + +test "activity compares the prior graph for the same project across interleaved updates" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const a1 = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"a","name":"A"},"nodes":[{"id":"a1","title":"A","state":"running"}],"edges":[]}}} + ; + const b1 = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"b","name":"B"},"nodes":[{"id":"b1","title":"B","state":"running"}],"edges":[]}}} + ; + const a2 = + \\{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"id":"a","project":{"path":"a","name":"A"},"nodes":[{"id":"a1","title":"A","state":"failed"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(a1); + _ = try model.updateFromFrame(b1); + _ = try model.updateFromFrame(a2); + try std.testing.expectEqual(@as(usize, 1), model.activity.items.len); + try std.testing.expectEqualStrings("A", model.activity.items[0].title); + try std.testing.expectEqualStrings("failed", model.activity.items[0].state); +} + +test "worktrees are limited to local filesystem projects" { + const local = Project{ .path = @constCast("C:\\work\\graph"), .name = @constCast("Graph") }; + const remote = Project{ .path = @constCast("ssh://host/graph"), .name = @constCast("Graph") }; + const global = Project{ .path = @constCast("graphcode://global"), .name = @constCast("Global") }; + try std.testing.expect(local.isLocalFilesystem()); + try std.testing.expect(!remote.isLocalFilesystem()); + try std.testing.expect(!global.isLocalFilesystem()); +} + +test "attention fixture preserves awaiting input and stranded edge metadata" { + const allocator = std.testing.allocator; + const frame = try std.fs.cwd().readFileAlloc( + allocator, + "fixtures/daemon-v2-graph-attention.json", + 64 * 1024, + ); + defer allocator.free(frame); + var model = Model.init(allocator); + defer model.deinit(); + try std.testing.expectEqual(Wire.EventKind.graph_changed, try model.updateFromFrame(frame)); + const graph = model.graph orelse return error.TestExpectedGraph; + try std.testing.expectEqualStrings("awaitingInput", graph.nodes.items[0].presence); + try std.testing.expectEqualStrings("handoff", graph.edges.items[0].kind); + try std.testing.expect(!graph.edges.items[0].fired); +} + +test "attention rollup surfaces awaiting input and failures" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"a","title":"Question","loopType":"turnBased","state":"running","presence":{"presence":"awaitingInput","confidence":"reported"}},{"id":"b","title":"Broken","loopType":"goalBased","state":"failed","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expectEqual(@as(usize, 2), model.attentionCount()); + try std.testing.expectEqualStrings("Broken", model.attention.items[0].title); + try std.testing.expectEqualStrings("Question", model.attention.items[1].title); +} + +test "activity log records state transitions but not initial snapshot" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"a","title":"Worker","loopType":"goalBased","state":"running","presence":{"presence":"busy","confidence":"reported"}}],"edges":[]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"a","title":"Worker","loopType":"goalBased","state":"succeeded","presence":{"presence":"idle","confidence":"reported"}}],"edges":[]}}} + ; + _ = try model.updateFromFrame(first); + try std.testing.expectEqual(@as(usize, 0), model.activity.items.len); + _ = try model.updateFromFrame(second); + try std.testing.expectEqual(@as(usize, 1), model.activity.items.len); + try std.testing.expectEqualStrings("Worker", model.activity.items[0].title); +} + +test "presence polling does not evict or create activity history" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"a","title":"Worker","loopType":"goalBased","state":"running","presence":{"presence":"busy","confidence":"reported"}}],"edges":[]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"a","title":"Worker","loopType":"goalBased","state":"running","presence":{"presence":"awaitingInput","confidence":"reported"}}],"edges":[]}}} + ; + _ = try model.updateFromFrame(first); + _ = try model.updateFromFrame(second); + try std.testing.expectEqual(@as(usize, 0), model.activity.items.len); +} + +test "blocked attention requires every blocking upstream to be resolved" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph","remote":false},"nodes":[{"id":"up","title":"Upstream","loopType":"goalBased","state":"failed"},{"id":"blocked","title":"Stranded","loopType":"goalBased","state":"blocked"},{"id":"live","title":"Live","loopType":"goalBased","state":"running"},{"id":"waiting","title":"Still waiting","loopType":"goalBased","state":"blocked"}],"edges":[{"from":"up","to":"blocked","kind":"handoff","fired":false},{"from":"live","to":"waiting","kind":"handoff","fired":false}]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expectEqual(@as(usize, 2), model.attentionCount()); + try std.testing.expectEqualStrings("Upstream", model.attention.items[0].title); + try std.testing.expectEqualStrings("Stranded", model.attention.items[1].title); +} + +test "attention fixture keeps real daemon state and worktree context visible" { + const frame = try std.fs.cwd().readFileAlloc( + std.testing.allocator, + "fixtures/daemon-v2-attention-worktree.json", + 64 * 1024, + ); + defer std.testing.allocator.free(frame); + var model = Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame(frame); + try std.testing.expectEqualStrings("Attention fixture", model.graph.?.project.name); + try std.testing.expectEqual(@as(usize, 2), model.attentionCount()); + try std.testing.expectEqualStrings("Failed check", model.attention.items[0].title); + try std.testing.expectEqualStrings("C:\\work\\graph-review", model.graph.?.nodes.items[0].worktree_path); + try std.testing.expectEqualStrings("review", model.graph.?.nodes.items[0].worktree_branch); +} + +test "real edge payload uses fireCount and only handoff blocks" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":3,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"a","title":"A","state":"succeeded"},{"id":"b","title":"B","state":"blocked"},{"id":"c","title":"C","state":"blocked"}],"edges":[{"from":"a","to":"b","kind":"handoff","fireCount":0},{"from":"a","to":"c","kind":"message","fireCount":0}]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expectEqual(@as(usize, 1), model.attentionCount()); + try std.testing.expectEqualStrings("B", model.attention.items[0].title); +} + +test "fireCount parses complete positive numeric tokens" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":4,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"a","title":"A","state":"running"},{"id":"b","title":"B","state":"blocked"}],"edges":[{"from":"a","to":"b","kind":"handoff","fireCount":1},{"from":"a","to":"b","kind":"handoff","fireCount":123}]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expectEqual(@as(u32, 1), model.graph.?.edges.items[0].fire_count); + try std.testing.expectEqual(@as(u32, 123), model.graph.?.edges.items[1].fire_count); + try std.testing.expectEqual(@as(usize, 0), model.attentionCount()); +} + +test "attention cursor is independent from ordinary selection" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"a","title":"A","state":"failed"},{"id":"b","title":"B","state":"failed"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expect(model.setSelectedIndex(1)); + model.selectNextAttention(); + try std.testing.expectEqual(@as(usize, 0), model.selectedIndex().?); + model.selectNextAttention(); + try std.testing.expectEqual(@as(usize, 1), model.selectedIndex().?); +} + +test "stable selection lookup survives reordered graph collections" { + const nodes = [_]Node{ + .{ .id = @constCast("node-b"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + .{ .id = @constCast("node-a"), .title = @constCast(""), .loop_type = @constCast(""), .state = @constCast(""), .activity = @constCast(""), .presence = @constCast("") }, + }; + const edges = [_]Edge{ + .{ .id = @constCast("edge-a"), .from = @constCast("node-a"), .to = @constCast("node-b") }, + .{ .id = @constCast("edge-b"), .from = @constCast("node-b"), .to = @constCast("node-a") }, + }; + try std.testing.expectEqual(@as(?usize, 1), findNodeIndexByID(&nodes, "node-a")); + try std.testing.expectEqual(@as(?usize, 1), findEdgeIndexByID(&edges, "edge-b")); + try std.testing.expect(findEdgeIndexByID(&edges, "missing") == null); +} + +test "stable selection survives a daemon graph refresh during a modal edit" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"node-a","title":"A","state":"running"}],"edges":[{"id":"edge-a","from":"node-a","to":"node-b","kind":"handoff"}]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"node-b","title":"B","state":"idle"},{"id":"node-a","title":"A","state":"running"}],"edges":[{"id":"edge-a","from":"node-a","to":"node-b","kind":"handoff"}]}}} + ; + _ = try model.updateFromFrame(first); + _ = try model.updateFromFrame(second); + try std.testing.expectEqual(@as(?usize, 1), model.findNodeIndex("node-a")); + try std.testing.expectEqual(@as(?usize, 0), model.findEdgeIndex("edge-a")); +} + +test "edge lookup is scoped to the selected project" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"A","name":"A"},"nodes":[{"id":"a","title":"A","state":"running"}],"edges":[{"id":"shared","from":"a","to":"a","kind":"handoff"}]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"B","name":"B"},"nodes":[{"id":"b","title":"B","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(first); + _ = try model.updateFromFrame(second); + try std.testing.expect(model.selectProject("B")); + try std.testing.expect(model.findEdgeIndex("shared") == null); + try std.testing.expect(model.selectProject("A")); + try std.testing.expectEqual(@as(?usize, 0), model.findEdgeIndex("shared")); +} + +test "composite subgraph payload preserves top-level child count" { + const payload = + \\{"nodes":[{"id":"a","title":"A","goal":{"summary":"nested"}},{"id":"b","title":"B"}],"edges":[{"from":"a","to":"b"}]} + ; + try std.testing.expectEqual(@as(usize, 2), subgraphNodeCount(payload)); + try std.testing.expectEqual(@as(usize, 0), subgraphNodeCount("{}")); +} + +test "real subGraph decoding preserves children without inheriting child scalar fields" { + var nodes = std.array_list.Managed(Node).init(std.testing.allocator); + defer { + for (nodes.items) |node| freeNode(std.testing.allocator, node); + nodes.deinit(); + } + const payload = + \\[{"id":"parent","title":"Parent","loopType":"proactive","subGraph":{"nodes":[{"id":"child","title":"Child","worktreeBinding":{"path":"C:\\child","branch":"nested"}}],"edges":[]},"state":"idle"}] + ; + try decodeNodes(std.testing.allocator, payload, &nodes); + try std.testing.expectEqual(@as(usize, 1), nodes.items.len); + try std.testing.expectEqual(@as(usize, 1), subgraphNodeCount(nodes.items[0].subgraph_json)); + try std.testing.expectEqualStrings("", nodes.items[0].worktree_path); + try std.testing.expectEqualStrings("idle", nodes.items[0].state); +} + +test "composite navigation swaps to nested graph and survives refresh" { + var model = Model.init(std.testing.allocator); + defer model.deinit(); + const first = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"parent","title":"Group","loopType":"composite","pilotState":"piloted","state":"idle","subGraph":{"nodes":[{"id":"child-a","title":"Child A","state":"idle"},{"id":"child-b","title":"Child B","state":"running"}],"edges":[{"id":"nested-edge","from":"child-a","to":"child-b","kind":"handoff"}]}}],"edges":[]}}} + ; + const second = + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"C:\\work\\graph","name":"Graph"},"nodes":[{"id":"parent","title":"Group","loopType":"composite","state":"idle","subGraph":{"nodes":[{"id":"child-b","title":"Child B updated","state":"succeeded"},{"id":"child-a","title":"Child A","state":"idle"}],"edges":[]}}],"edges":[]}}} + ; + _ = try model.updateFromFrame(first); + try std.testing.expectEqualStrings("piloted", model.graph.?.nodes.items[0].pilot_state); + try std.testing.expect(model.openComposite("parent")); + try std.testing.expect(model.isCompositeOpen()); + try std.testing.expectEqualStrings("Group", model.open_composite_title.?); + try std.testing.expectEqual(@as(usize, 2), model.graph.?.nodes.items.len); + try std.testing.expectEqual(@as(usize, 1), model.graph.?.edges.items.len); + try std.testing.expect(model.setSelectedID("child-b")); + + _ = try model.updateFromFrame(second); + try std.testing.expect(model.isCompositeOpen()); + try std.testing.expectEqualStrings("Child B updated", model.selected().?.title); + try std.testing.expectEqual(@as(usize, 0), model.graph.?.edges.items.len); + + model.closeComposite(); + try std.testing.expect(!model.isCompositeOpen()); + try std.testing.expectEqual(@as(usize, 1), model.graph.?.nodes.items.len); + try std.testing.expectEqualStrings("parent", model.selected().?.id); +} diff --git a/graphcode-windows/src/InputRouter.zig b/graphcode-windows/src/InputRouter.zig new file mode 100644 index 00000000..ff9bae07 --- /dev/null +++ b/graphcode-windows/src/InputRouter.zig @@ -0,0 +1,227 @@ +const std = @import("std"); + +pub const Action = enum { + none, + reconnect, + open_folder, + create_node, + open_node, + stop_node, + send_node, + edit_node, + rename_selected, + delete_selected, + create_edge, + jump_next, + command_palette, + next_identity, + previous_identity, + quick_chat, + rename_quick_chat, + delete_quick_chat, + settings, + product_settings, + clone_repository, + cancel_clone, + remote_repository, + onboarding, + cycle_attention, + inspect_worktrees, + reclaim_worktrees, + reveal_worktree, + edit_worktree_policy, + save_worktree_policy, + worktree_next, + worktree_previous, + focus_terminal_a, + focus_terminal_b, + select_next, + select_previous, + new_tab, + close_tab, + split_horizontal, + split_vertical, + focus_next_pane, + focus_previous_pane, + select_previous_tab, + select_next_tab, + show_graph, + toggle_rail, + toggle_panel, + toggle_activity, + zoom_out, + actual_size, + zoom_in, + fit_canvas, +}; + +pub fn keyAction(key: usize, ctrl: bool, shift: bool) Action { + if (ctrl and shift and key == ',') return .product_settings; + if (ctrl and shift and key == 'C') return .clone_repository; + if (ctrl and shift and key == 'X') return .cancel_clone; + if (ctrl and shift and key == 'R') return .remote_repository; + if (ctrl and key == 'R' and !shift) return .reconnect; + if (ctrl and key == 'N') return .create_node; + if (ctrl and key == 'O') return .open_folder; + if (ctrl and shift and key == 'I') return .inspect_worktrees; + if (ctrl and shift and key == 'S') return .save_worktree_policy; + if (ctrl and shift and key == 'P') return .edit_worktree_policy; + if (ctrl and key == 'S') return .stop_node; + if (ctrl and key == 'M') return .send_node; + if (ctrl and key == 'E' and shift) return .reveal_worktree; + if (ctrl and key == 'E') return .edit_node; + if (!ctrl and key == 0x71) return .rename_selected; + if (!ctrl and key == 0x2E) return .delete_selected; + if (ctrl and key == 'J') return .jump_next; + if (ctrl and key == 'P' and !shift) return .command_palette; + if (ctrl and key == 0x28) return .next_identity; + if (ctrl and key == 0x26) return .previous_identity; + if (ctrl and key == 'Q' and !shift) return .quick_chat; + if (ctrl and key == 'Q' and shift) return .rename_quick_chat; + if (ctrl and shift and key == 0x2E) return .delete_quick_chat; + if (ctrl and (key == ',' or key == 0xBC)) return .settings; + if (key == 0x70) return .onboarding; + if (ctrl and key == 0x09) return .cycle_attention; + if (ctrl and key == 'W' and shift) return .inspect_worktrees; + if (!ctrl and key == 0x28) return .worktree_next; + if (!ctrl and key == 0x26) return .worktree_previous; + if (key == 0x31) return .focus_terminal_a; + if (key == 0x32) return .focus_terminal_b; + if (key == 0x09 and shift) return .select_previous; + if (key == 0x09) return .select_next; + if (ctrl and key == 'D' and shift) return .split_vertical; + if (ctrl and key == 'D') return .split_horizontal; + if (ctrl and key == 'W') return .close_tab; + if (ctrl and key == 'T') return .new_tab; + if (ctrl and key == 0xDB) return .focus_previous_pane; + if (ctrl and key == 0xDD) return .focus_next_pane; + if (ctrl and key == 0x21) return .select_previous_tab; + if (ctrl and key == 0x22) return .select_next_tab; + if (ctrl and key == 'G' and shift) return .show_graph; + if (ctrl and key == 'L' and shift) return .toggle_rail; + if (ctrl and key == 'B' and shift) return .toggle_panel; + if (ctrl and key == 'A' and shift) return .toggle_activity; + if (ctrl and !shift and key == 0xBD) return .zoom_out; + if (ctrl and !shift and key == '0') return .actual_size; + if (ctrl and !shift and key == 0xBB) return .zoom_in; + if (ctrl and !shift and key == '9') return .fit_canvas; + return .none; +} + +pub fn commandText(allocator: std.mem.Allocator, action: Action) ![]u8 { + return switch (action) { + .open_folder => allocator.dupe(u8, "Open folder"), + .create_node => allocator.dupe(u8, "Create node"), + .open_node => allocator.dupe(u8, "Open node"), + .stop_node => allocator.dupe(u8, "Stop node"), + .send_node => allocator.dupe(u8, "Send node"), + .edit_node => allocator.dupe(u8, "Edit node"), + .rename_selected => allocator.dupe(u8, "Rename selected loop"), + .delete_selected => allocator.dupe(u8, "Delete selected canvas item"), + .create_edge => allocator.dupe(u8, "Create edge"), + .jump_next => allocator.dupe(u8, "Jump to next node"), + .command_palette => allocator.dupe(u8, "Command / jump palette"), + .next_identity => allocator.dupe(u8, "Next loop"), + .previous_identity => allocator.dupe(u8, "Previous loop"), + .quick_chat => allocator.dupe(u8, "Quick chats (protocol blocked)"), + .rename_quick_chat => allocator.dupe(u8, "Rename quick chat"), + .delete_quick_chat => allocator.dupe(u8, "Delete quick chat"), + .settings => allocator.dupe(u8, "Settings"), + .product_settings => allocator.dupe(u8, "Product settings"), + .clone_repository => allocator.dupe(u8, "Clone HTTPS repository"), + .cancel_clone => allocator.dupe(u8, "Cancel clone"), + .remote_repository => allocator.dupe(u8, "Add SSH repository"), + .onboarding => allocator.dupe(u8, "GraphCode onboarding"), + .cycle_attention => allocator.dupe(u8, "Review next loop needing you"), + .inspect_worktrees => allocator.dupe(u8, "Inspect worktrees"), + .reclaim_worktrees => allocator.dupe(u8, "Reclaim selected worktrees"), + .reveal_worktree => allocator.dupe(u8, "Reveal selected worktree in Explorer"), + .edit_worktree_policy => allocator.dupe(u8, "Edit worktree policy"), + .save_worktree_policy => allocator.dupe(u8, "Save worktree policy"), + .worktree_next => allocator.dupe(u8, "Select next worktree row"), + .worktree_previous => allocator.dupe(u8, "Select previous worktree row"), + .reconnect => allocator.dupe(u8, "Reconnect"), + .focus_terminal_a => allocator.dupe(u8, "Focus terminal A"), + .focus_terminal_b => allocator.dupe(u8, "Focus terminal B"), + .select_next => allocator.dupe(u8, "Select next node"), + .select_previous => allocator.dupe(u8, "Select previous node"), + .new_tab => allocator.dupe(u8, "New terminal tab"), + .close_tab => allocator.dupe(u8, "Close terminal tab"), + .split_horizontal => allocator.dupe(u8, "Split terminal right"), + .split_vertical => allocator.dupe(u8, "Split terminal down"), + .focus_next_pane => allocator.dupe(u8, "Focus next pane"), + .focus_previous_pane => allocator.dupe(u8, "Focus previous pane"), + .select_previous_tab => allocator.dupe(u8, "Select previous terminal tab"), + .select_next_tab => allocator.dupe(u8, "Select next terminal tab"), + .show_graph => allocator.dupe(u8, "Show graph"), + .toggle_rail => allocator.dupe(u8, "Toggle workspace rail"), + .toggle_panel => allocator.dupe(u8, "Toggle workspace panel"), + .toggle_activity => allocator.dupe(u8, "Toggle activity"), + .zoom_out => allocator.dupe(u8, "Zoom out"), + .actual_size => allocator.dupe(u8, "Actual size"), + .zoom_in => allocator.dupe(u8, "Zoom in"), + .fit_canvas => allocator.dupe(u8, "Fit canvas"), + .none => allocator.dupe(u8, ""), + }; +} + +test "workspace shortcuts route to tabs splits and panes" { + try std.testing.expectEqual(Action.new_tab, keyAction('T', true, false)); + try std.testing.expectEqual(Action.close_tab, keyAction('W', true, false)); + try std.testing.expectEqual(Action.split_horizontal, keyAction('D', true, false)); + try std.testing.expectEqual(Action.split_vertical, keyAction('D', true, true)); + try std.testing.expectEqual(Action.focus_previous_pane, keyAction(0xDB, true, false)); + try std.testing.expectEqual(Action.focus_next_pane, keyAction(0xDD, true, false)); + try std.testing.expectEqual(Action.select_previous_tab, keyAction(0x21, true, false)); + try std.testing.expectEqual(Action.select_next_tab, keyAction(0x22, true, false)); +} + +test "attention and worktree shortcuts are distinct from ordinary selection" { + try std.testing.expectEqual(Action.cycle_attention, keyAction(0x09, true, false)); + try std.testing.expectEqual(Action.close_tab, keyAction('W', true, false)); + try std.testing.expectEqual(Action.inspect_worktrees, keyAction('W', true, true)); + try std.testing.expectEqual(Action.select_next, keyAction(0x09, false, false)); + try std.testing.expectEqual(Action.select_previous, keyAction(0x09, false, true)); +} + +test "OEM comma routes to settings only with control" { + try std.testing.expectEqual(Action.settings, keyAction(0xBC, true, false)); + try std.testing.expectEqual(Action.none, keyAction(0xBC, false, false)); +} + +test "tab variants keep terminal navigation distinct" { + try std.testing.expectEqual(Action.select_next, keyAction(0x09, false, false)); + try std.testing.expectEqual(Action.select_previous, keyAction(0x09, false, true)); + try std.testing.expectEqual(Action.cycle_attention, keyAction(0x09, true, false)); +} + +test "canvas destructive and rename keyboard equivalents are explicit" { + try std.testing.expectEqual(Action.delete_selected, keyAction(0x2E, false, false)); + try std.testing.expectEqual(Action.rename_selected, keyAction(0x71, false, false)); +} + +test "modifier-specific actions win over base shortcuts" { + try std.testing.expectEqual(Action.product_settings, keyAction(',', true, true)); + try std.testing.expectEqual(Action.remote_repository, keyAction('R', true, true)); + try std.testing.expectEqual(Action.clone_repository, keyAction('C', true, true)); + try std.testing.expectEqual(Action.reconnect, keyAction('R', true, false)); + try std.testing.expectEqual(Action.edit_worktree_policy, keyAction('P', true, true)); + try std.testing.expectEqual(Action.save_worktree_policy, keyAction('S', true, true)); + try std.testing.expectEqual(Action.inspect_worktrees, keyAction('I', true, true)); +} + +test "parity shortcuts expose palette navigation quick chat and workspace controls" { + try std.testing.expectEqual(Action.command_palette, keyAction('P', true, false)); + try std.testing.expectEqual(Action.next_identity, keyAction(0x28, true, false)); + try std.testing.expectEqual(Action.previous_identity, keyAction(0x26, true, false)); + try std.testing.expectEqual(Action.quick_chat, keyAction('Q', true, false)); + try std.testing.expectEqual(Action.rename_quick_chat, keyAction('Q', true, true)); + try std.testing.expectEqual(Action.delete_quick_chat, keyAction(0x2E, true, true)); + try std.testing.expectEqual(Action.toggle_rail, keyAction('L', true, true)); + try std.testing.expectEqual(Action.toggle_panel, keyAction('B', true, true)); + try std.testing.expectEqual(Action.toggle_activity, keyAction('A', true, true)); + try std.testing.expectEqual(Action.zoom_out, keyAction(0xBD, true, false)); + try std.testing.expectEqual(Action.actual_size, keyAction('0', true, false)); + try std.testing.expectEqual(Action.zoom_in, keyAction(0xBB, true, false)); + try std.testing.expectEqual(Action.fit_canvas, keyAction('9', true, false)); +} diff --git a/graphcode-windows/src/JumpPalette.zig b/graphcode-windows/src/JumpPalette.zig new file mode 100644 index 00000000..c0779db2 --- /dev/null +++ b/graphcode-windows/src/JumpPalette.zig @@ -0,0 +1,403 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const Entry = struct { + project_path: []const u8, + project_name: []const u8, + node_id: []const u8, + title: []const u8, + loop_type: []const u8, + state: []const u8, +}; + +pub const Match = struct { + entry_index: usize, + score: u8, +}; + +pub const Selection = struct { + project_path: []u8, + node_id: []u8, + + pub fn deinit(self: Selection, allocator: std.mem.Allocator) void { + allocator.free(self.project_path); + allocator.free(self.node_id); + } +}; + +pub const State = struct { + allocator: std.mem.Allocator, + entries: []const Entry, + matches: std.array_list.Managed(Match), + selection: usize = 0, + + pub fn init(allocator: std.mem.Allocator, entries: []const Entry) State { + return .{ + .allocator = allocator, + .entries = entries, + .matches = std.array_list.Managed(Match).init(allocator), + }; + } + + pub fn deinit(self: *State) void { + self.matches.deinit(); + } + + pub fn filter(self: *State, raw_query: []const u8) !void { + self.matches.clearRetainingCapacity(); + const query = std.mem.trim(u8, raw_query, " \t\r\n"); + for (self.entries, 0..) |entry, index| { + const score = rank(entry, query) orelse continue; + try self.matches.append(.{ .entry_index = index, .score = score }); + } + std.mem.sort(Match, self.matches.items, {}, struct { + fn lessThan(_: void, lhs: Match, rhs: Match) bool { + if (lhs.score != rhs.score) return lhs.score < rhs.score; + return lhs.entry_index < rhs.entry_index; + } + }.lessThan); + self.selection = 0; + } + + pub fn moveSelection(self: *State, delta: i32) void { + if (self.matches.items.len == 0) { + self.selection = 0; + return; + } + const last = self.matches.items.len - 1; + if (delta < 0) { + self.selection = if (self.selection == 0) last else self.selection - 1; + } else if (delta > 0) { + self.selection = if (self.selection >= last) 0 else self.selection + 1; + } + } + + pub fn selectedEntry(self: *const State) ?Entry { + if (self.selection >= self.matches.items.len) return null; + return self.entries[self.matches.items[self.selection].entry_index]; + } +}; + +fn rank(entry: Entry, query: []const u8) ?u8 { + if (query.len == 0) return 4; + if (std.ascii.eqlIgnoreCase(entry.node_id, query)) return 0; + if (std.ascii.eqlIgnoreCase(entry.title, query)) return 1; + if (startsWithIgnoreCase(entry.title, query)) return 2; + if (containsIgnoreCase(entry.title, query) or containsIgnoreCase(entry.node_id, query)) return 3; + return null; +} + +fn startsWithIgnoreCase(value: []const u8, prefix: []const u8) bool { + return value.len >= prefix.len and std.ascii.eqlIgnoreCase(value[0..prefix.len], prefix); +} + +fn containsIgnoreCase(value: []const u8, needle: []const u8) bool { + if (needle.len == 0 or needle.len > value.len) return false; + var index: usize = 0; + while (index + needle.len <= value.len) : (index += 1) { + if (std.ascii.eqlIgnoreCase(value[index .. index + needle.len], needle)) return true; + } + return false; +} + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeJumpPalette"); +const search_id = 9401; +const results_id = 9402; +const ok_id = 1; +const cancel_id = 2; + +const Dialog = struct { + state: State, + edit: c.HWND = null, + list: c.HWND = null, + accepted: bool = false, + closed: bool = false, +}; + +var active: ?*Dialog = null; + +pub fn show( + parent: c.HWND, + allocator: std.mem.Allocator, + entries: []const Entry, +) !?Selection { + try registerClass(); + var dialog = Dialog{ .state = State.init(allocator, entries) }; + defer dialog.state.deinit(); + try dialog.state.filter(""); + active = &dialog; + defer active = null; + + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + class_name.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Jump to loop").ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 640, + 430, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.PaletteCreationFailed; + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + _ = c.SetFocus(dialog.edit); + + var message: c.MSG = undefined; + var quit_code: ?c.WPARAM = null; + while (!dialog.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + dialog.closed = true; + if (code == 0) quit_code = message.wParam; + break; + } + if (message.message == c.WM_KEYDOWN) { + switch (message.wParam) { + c.VK_DOWN => { + dialog.state.moveSelection(1); + syncListSelection(&dialog); + continue; + }, + c.VK_UP => { + dialog.state.moveSelection(-1); + syncListSelection(&dialog); + continue; + }, + c.VK_RETURN => { + accept(&dialog); + continue; + }, + c.VK_ESCAPE => { + dialog.closed = true; + continue; + }, + else => {}, + } + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + const selected = if (dialog.accepted) dialog.state.selectedEntry() else null; + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + if (quit_code) |value| c.PostQuitMessage(@intCast(value)); + const entry = selected orelse return null; + return .{ + .project_path = try allocator.dupe(u8, entry.project_path), + .node_id = try allocator.dupe(u8, entry.node_id), + }; +} + +fn registerClass() !void { + var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + window_class.lpfnWndProc = @ptrCast(&windowProc); + window_class.hInstance = c.GetModuleHandleW(null); + window_class.lpszClassName = class_name.ptr; + window_class.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + if (c.RegisterClassW(&window_class) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.PaletteClassRegistrationFailed; +} + +fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + const dialog = active orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_CREATE => { + _ = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Search loops").ptr, + c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, + 16, 14, 590, 20, hwnd, null, c.GetModuleHandleW(null), null, + ); + dialog.edit = c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + null, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL, + 16, 36, 590, 28, hwnd, childId(search_id), c.GetModuleHandleW(null), null, + ); + dialog.list = c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("LISTBOX").ptr, + null, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.WS_VSCROLL | + c.LBS_NOTIFY | c.LBS_NOINTEGRALHEIGHT, + 16, 76, 590, 276, hwnd, childId(results_id), c.GetModuleHandleW(null), null, + ); + refillList(dialog); + return 0; + }, + c.WM_SIZE => { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + _ = c.MoveWindow(dialog.edit, 16, 36, client.right - 32, 28, 1); + _ = c.MoveWindow(dialog.list, 16, 76, client.right - 32, client.bottom - 92, 1); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + const notification: u16 = @truncate(wparam >> 16); + if (command == search_id and notification == c.EN_CHANGE) { + const query = readWindowText(dialog.state.allocator, dialog.edit) catch return 0; + defer dialog.state.allocator.free(query); + dialog.state.filter(query) catch return 0; + refillList(dialog); + return 0; + } + if (command == results_id and notification == c.LBN_SELCHANGE) { + const selected = c.SendMessageW(dialog.list, c.LB_GETCURSEL, 0, 0); + if (selected >= 0) dialog.state.selection = @intCast(selected); + return 0; + } + if (command == results_id and notification == c.LBN_DBLCLK) { + accept(dialog); + return 0; + } + if (command == ok_id) { + accept(dialog); + return 0; + } + if (command == cancel_id) { + dialog.closed = true; + return 0; + } + }, + c.WM_APP + 43 => { + if (!envFlag("GRAPHCODE_UIA_GATE")) return 0; + dialog.state.filter("UIA loop B") catch return 0; + refillList(dialog); + return 0; + }, + c.WM_CLOSE => { + dialog.closed = true; + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn refillList(dialog: *Dialog) void { + _ = c.SendMessageW(dialog.list, c.LB_RESETCONTENT, 0, 0); + for (dialog.state.matches.items) |match| { + const entry = dialog.state.entries[match.entry_index]; + const line = std.fmt.allocPrint( + dialog.state.allocator, + "{s} — {s} · {s} · {s}", + .{ entry.title, entry.project_name, loopTypeLabel(entry.loop_type), stateLabel(entry.state) }, + ) catch continue; + defer dialog.state.allocator.free(line); + const wide = utf8ToWideZ(dialog.state.allocator, line) catch continue; + defer dialog.state.allocator.free(wide); + _ = c.SendMessageW(dialog.list, c.LB_ADDSTRING, 0, @intCast(@intFromPtr(wide.ptr))); + } + syncListSelection(dialog); +} + +fn syncListSelection(dialog: *Dialog) void { + if (dialog.state.matches.items.len == 0) return; + _ = c.SendMessageW(dialog.list, c.LB_SETCURSEL, dialog.state.selection, 0); +} + +fn accept(dialog: *Dialog) void { + if (dialog.state.selectedEntry() == null) return; + dialog.accepted = true; + dialog.closed = true; +} + +fn loopTypeLabel(value: []const u8) []const u8 { + if (std.mem.eql(u8, value, "goalBased")) return "Goal"; + if (std.mem.eql(u8, value, "timeBased")) return "Timed"; + if (std.mem.eql(u8, value, "proactive") or std.mem.eql(u8, value, "composite")) return "Proactive"; + return "Turn"; +} + +fn stateLabel(value: []const u8) []const u8 { + if (value.len == 0) return "IDLE"; + return value; +} + +fn childId(value: usize) c.HMENU { + @setRuntimeSafety(false); + return @ptrFromInt(value); +} + +fn readWindowText(allocator: std.mem.Allocator, hwnd: c.HWND) ![]u8 { + const length = c.GetWindowTextLengthW(hwnd); + const wide = try allocator.alloc(u16, @as(usize, @intCast(length)) + 1); + defer allocator.free(wide); + const copied = c.GetWindowTextW(hwnd, wide.ptr, length + 1); + return try std.unicode.utf16LeToUtf8Alloc(allocator, wide[0..@intCast(copied)]); +} + +fn utf8ToWideZ(allocator: std.mem.Allocator, value: []const u8) ![:0]u16 { + const converted = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(converted); + const result = try allocator.allocSentinel(u16, converted.len, 0); + @memcpy(result[0..converted.len], converted); + return result; +} + +fn envFlag(name: []const u8) bool { + const allocator = std.heap.c_allocator; + const value = std.process.getEnvVarOwned(allocator, name) catch return false; + defer allocator.free(value); + return value.len != 0 and !std.mem.eql(u8, value, "0"); +} + +test "filter preserves exact ranking and deterministic cross-project order" { + const entries = [_]Entry{ + .{ .project_path = "A", .project_name = "Alpha", .node_id = "first", .title = "Authentication audit", .loop_type = "turnBased", .state = "idle" }, + .{ .project_path = "B", .project_name = "Beta", .node_id = "auth", .title = "Unrelated", .loop_type = "goalBased", .state = "running" }, + .{ .project_path = "B", .project_name = "Beta", .node_id = "third", .title = "Fix authentication", .loop_type = "proactive", .state = "failed" }, + }; + var state = State.init(std.testing.allocator, &entries); + defer state.deinit(); + + try state.filter("AUTH"); + try std.testing.expectEqual(@as(usize, 1), state.matches.items[0].entry_index); + try std.testing.expectEqual(@as(u8, 0), state.matches.items[0].score); + + try state.filter("unrelated"); + try std.testing.expectEqual(@as(usize, 1), state.matches.items[0].entry_index); + try std.testing.expectEqual(@as(u8, 1), state.matches.items[0].score); + + try state.filter("authentication"); + try std.testing.expectEqualSlices(usize, &.{ 0, 2 }, &.{ + state.matches.items[0].entry_index, + state.matches.items[1].entry_index, + }); + try std.testing.expectEqual(@as(u8, 2), state.matches.items[0].score); + try std.testing.expectEqual(@as(u8, 3), state.matches.items[1].score); + + try state.filter("FIX AUTH"); + try std.testing.expectEqual(@as(usize, 2), state.matches.items[0].entry_index); + try std.testing.expectEqual(@as(u8, 2), state.matches.items[0].score); +} + +test "empty filtering and selection movement wrap safely" { + const entries = [_]Entry{ + .{ .project_path = "A", .project_name = "Alpha", .node_id = "one", .title = "One", .loop_type = "turnBased", .state = "idle" }, + .{ .project_path = "B", .project_name = "Beta", .node_id = "two", .title = "Two", .loop_type = "goalBased", .state = "running" }, + }; + var state = State.init(std.testing.allocator, &entries); + defer state.deinit(); + + try state.filter(" "); + try std.testing.expectEqual(@as(usize, 2), state.matches.items.len); + state.moveSelection(-1); + try std.testing.expectEqual(@as(usize, 1), state.selection); + state.moveSelection(1); + try std.testing.expectEqual(@as(usize, 0), state.selection); + try state.filter("missing"); + state.moveSelection(1); + try std.testing.expectEqual(@as(usize, 0), state.selection); + try std.testing.expect(state.selectedEntry() == null); +} diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig new file mode 100644 index 00000000..fe21a8eb --- /dev/null +++ b/graphcode-windows/src/MainWindow.zig @@ -0,0 +1,386 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const MessageCallback = *const fn ( + context: ?*anyopaque, + hwnd: c.HWND, + message: c.UINT, + wparam: c.WPARAM, + lparam: c.LPARAM, + result: *c.LRESULT, +) callconv(.c) bool; + +pub const Command = enum(u16) { + open_folder = 4101, + open_global_overview = 4102, + worktrees = 4103, + exit = 4104, + reclaim_worktrees = 4105, + clone_repository = 4106, + remote_repository = 4107, + new_quick_chat = 4108, + jump_loop = 4201, + review_attention = 4202, + next_loop = 4203, + previous_loop = 4204, + create_node = 4205, + create_edge = 4206, + stop_loop = 4207, + show_graph = 4208, + new_tab = 4301, + close_tab = 4302, + split_right = 4303, + split_down = 4304, + next_tab = 4305, + previous_tab = 4306, + focus_next_pane = 4307, + focus_previous_pane = 4308, + reconnect = 4401, + settings = 4402, + product_settings = 4403, + toggle_sidebar = 4404, + toggle_workspace = 4405, + toggle_activity = 4406, + zoom_out = 4407, + actual_size = 4408, + zoom_in = 4409, + fit_canvas = 4410, + about = 4501, + onboarding = 4502, + check_updates = 4503, + reveal_worktree = 4504, + edit_worktree_policy = 4505, + save_worktree_policy = 4506, +}; + +pub const empty_open_folder_id: usize = 4601; +pub const empty_new_loop_id: usize = 4602; + +pub const MenuState = struct { + has_project: bool, + can_worktrees: bool, + has_workspace: bool, + has_attention: bool, + can_close_tab: bool, + sidebar_visible: bool, + workspace_visible: bool, + activity_visible: bool, + update_checking: bool, +}; + +pub fn commandFromId(id: usize) ?Command { + return std.meta.intToEnum(Command, @as(u16, @intCast(id))) catch null; +} + +pub const Window = struct { + hwnd: c.HWND = null, + instance: c.HINSTANCE = null, + context: ?*anyopaque = null, + callback: ?MessageCallback = null, + accelerators: c.HACCEL = null, + class_name: [*:0]const u16 = class_name.ptr, + + pub fn create( + self: *Window, + context: ?*anyopaque, + callback: MessageCallback, + title: [*:0]const u16, + ) !void { + self.instance = c.GetModuleHandleW(null); + if (restore_message == 0) { + restore_message = c.RegisterWindowMessageW( + std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.Restore").ptr, + ); + } + self.context = context; + self.callback = callback; + try registerClass(self.instance); + self.hwnd = c.CreateWindowExW( + 0, + class_name.ptr, + title, + c.WS_OVERLAPPEDWINDOW | c.WS_CLIPCHILDREN, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 1280, + 820, + null, + null, + self.instance, + @ptrCast(self), + ) orelse return error.WindowCreationFailed; + try installMenu(self.hwnd); + self.accelerators = createAccelerators(); + _ = c.ShowWindow(self.hwnd, c.SW_SHOW); + _ = c.UpdateWindow(self.hwnd); + _ = c.SetTimer(self.hwnd, timer_id, 100, null); + } + + pub fn destroy(self: *Window) void { + if (self.accelerators != null) { + _ = c.DestroyAcceleratorTable(self.accelerators); + self.accelerators = null; + } + if (self.hwnd != null and c.IsWindow(self.hwnd) != 0) { + _ = c.DestroyWindow(self.hwnd); + } + self.hwnd = null; + } + + pub fn messageLoop(self: *Window) !void { + var message: c.MSG = undefined; + while (true) { + const result = c.GetMessageW(&message, null, 0, 0); + if (result == 0) break; + if (result == -1) return error.MessageLoopFailed; + if (self.accelerators != null and c.TranslateAcceleratorW(self.hwnd, self.accelerators, &message) != 0) + continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + } +}; + +pub const timer_id: usize = 41; +pub const wm_app_tick: c.UINT = c.WM_APP + 41; +pub var restore_message: c.UINT = 0; +pub const wm_uia_fixture_mutate: c.UINT = c.WM_APP + 42; + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWindowsShell"); + +pub fn restoreExistingInstance() void { + const hwnd = c.FindWindowW(class_name.ptr, null); + const message = c.RegisterWindowMessageW(std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.Restore").ptr); + if (hwnd != null and message != 0) { + var process_id: c.DWORD = 0; + _ = c.GetWindowThreadProcessId(hwnd, &process_id); + if (process_id != 0) _ = c.AllowSetForegroundWindow(process_id); + _ = c.ShowWindow(hwnd, c.SW_RESTORE); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.BringWindowToTop(hwnd); + _ = c.SetForegroundWindow(hwnd); + _ = c.PostMessageW(hwnd, message, 0, 0); + } +} + +pub fn installMenu(hwnd: c.HWND) !void { + const menu = c.CreateMenu() orelse return error.MenuCreationFailed; + const file = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const loop = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const terminal = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const view = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const help = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + + append(file, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); + append(file, "Clone Repository...\tCtrl+Shift+C", @intFromEnum(Command.clone_repository)); + append(file, "Add Remote Repository...\tCtrl+Shift+R", @intFromEnum(Command.remote_repository)); + separator(file); + append(file, "New Quick Chat\tCtrl+Q", @intFromEnum(Command.new_quick_chat)); + append(file, "Open Global Overview", @intFromEnum(Command.open_global_overview)); + separator(file); + append(file, "Worktrees...\tCtrl+Shift+W", @intFromEnum(Command.worktrees)); + append(file, "Reclaim Selected Worktrees...", @intFromEnum(Command.reclaim_worktrees)); + append(file, "Reveal Selected Worktree in Explorer\tCtrl+Shift+E", @intFromEnum(Command.reveal_worktree)); + append(file, "Project Worktree Policy...", @intFromEnum(Command.edit_worktree_policy)); + append(file, "Save Worktree Policy\tCtrl+Shift+S", @intFromEnum(Command.save_worktree_policy)); + separator(file); + append(file, "Exit", @intFromEnum(Command.exit)); + + append(loop, "Jump to Loop...\tCtrl+J", @intFromEnum(Command.jump_loop)); + append(loop, "Review What Needs You\tCtrl+Tab", @intFromEnum(Command.review_attention)); + separator(loop); + append(loop, "Next Loop\tTab", @intFromEnum(Command.next_loop)); + append(loop, "Previous Loop\tShift+Tab", @intFromEnum(Command.previous_loop)); + separator(loop); + append(loop, "New Loop...\tCtrl+N", @intFromEnum(Command.create_node)); + append(loop, "Create Edge...", @intFromEnum(Command.create_edge)); + append(loop, "Show in Graph", @intFromEnum(Command.show_graph)); + append(loop, "Stop Loop\tCtrl+S", @intFromEnum(Command.stop_loop)); + + append(terminal, "New Tab\tCtrl+T", @intFromEnum(Command.new_tab)); + append(terminal, "Close Tab\tCtrl+W", @intFromEnum(Command.close_tab)); + separator(terminal); + append(terminal, "Split Right\tCtrl+D", @intFromEnum(Command.split_right)); + append(terminal, "Split Down\tCtrl+Shift+D", @intFromEnum(Command.split_down)); + separator(terminal); + append(terminal, "Next Tab\tCtrl+PageDown", @intFromEnum(Command.next_tab)); + append(terminal, "Previous Tab\tCtrl+PageUp", @intFromEnum(Command.previous_tab)); + append(terminal, "Focus Next Pane\tCtrl+]", @intFromEnum(Command.focus_next_pane)); + append(terminal, "Focus Previous Pane\tCtrl+[", @intFromEnum(Command.focus_previous_pane)); + + append(view, "Global Overview", @intFromEnum(Command.open_global_overview)); + append(view, "Show Application Sidebar\tCtrl+Shift+L", @intFromEnum(Command.toggle_sidebar)); + append(view, "Show Terminal Workspace\tCtrl+Shift+B", @intFromEnum(Command.toggle_workspace)); + append(view, "Show Activity Strip\tCtrl+Shift+A", @intFromEnum(Command.toggle_activity)); + separator(view); + append(view, "Zoom Out\tCtrl+-", @intFromEnum(Command.zoom_out)); + append(view, "Actual Size\tCtrl+0", @intFromEnum(Command.actual_size)); + append(view, "Zoom In\tCtrl+=", @intFromEnum(Command.zoom_in)); + append(view, "Fit Canvas\tCtrl+9", @intFromEnum(Command.fit_canvas)); + separator(view); + append(view, "Reconnect", @intFromEnum(Command.reconnect)); + append(view, "Settings...\tCtrl+Shift+,", @intFromEnum(Command.product_settings)); + append(view, "Advanced Connection Settings...\tCtrl+,", @intFromEnum(Command.settings)); + append(help, "GraphCode Basics\tF1", @intFromEnum(Command.onboarding)); + append(help, "Check for Updates...", @intFromEnum(Command.check_updates)); + separator(help); + append(help, "About GraphCode", @intFromEnum(Command.about)); + + appendPopup(menu, "File", file); + appendPopup(menu, "Loop", loop); + appendPopup(menu, "Terminal", terminal); + appendPopup(menu, "View", view); + appendPopup(menu, "Help", help); + if (c.SetMenu(hwnd, menu) == 0) return error.MenuInstallFailed; + _ = c.DrawMenuBar(hwnd); +} + +pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { + setEnabled(hwnd, .open_global_overview, true); + setEnabled(hwnd, .worktrees, state.can_worktrees); + setEnabled(hwnd, .reclaim_worktrees, state.can_worktrees); + setEnabled(hwnd, .reveal_worktree, state.can_worktrees); + setEnabled(hwnd, .edit_worktree_policy, state.can_worktrees); + setEnabled(hwnd, .save_worktree_policy, state.can_worktrees); + setEnabled(hwnd, .jump_loop, state.has_project); + setEnabled(hwnd, .review_attention, state.has_attention); + setEnabled(hwnd, .next_loop, state.has_project); + setEnabled(hwnd, .previous_loop, state.has_project); + setEnabled(hwnd, .create_node, state.has_project); + setEnabled(hwnd, .create_edge, state.has_project); + setEnabled(hwnd, .stop_loop, state.has_project); + setEnabled(hwnd, .show_graph, state.has_workspace); + setEnabled(hwnd, .new_tab, state.has_workspace); + setEnabled(hwnd, .close_tab, state.can_close_tab); + setEnabled(hwnd, .split_right, state.has_workspace); + setEnabled(hwnd, .split_down, state.has_workspace); + setEnabled(hwnd, .next_tab, state.has_workspace); + setEnabled(hwnd, .previous_tab, state.has_workspace); + setEnabled(hwnd, .focus_next_pane, state.has_workspace); + setEnabled(hwnd, .focus_previous_pane, state.has_workspace); + setEnabled(hwnd, .settings, true); + setEnabled(hwnd, .product_settings, true); + setEnabled(hwnd, .reconnect, true); + setEnabled(hwnd, .check_updates, !state.update_checking); + setChecked(hwnd, .toggle_sidebar, state.sidebar_visible); + setChecked(hwnd, .toggle_workspace, state.workspace_visible); + setChecked(hwnd, .toggle_activity, state.activity_visible); + _ = c.DrawMenuBar(hwnd); +} + +fn setEnabled(hwnd: c.HWND, command: Command, enabled: bool) void { + const flags: c.UINT = @intCast(@as(i32, c.MF_BYCOMMAND) | + if (enabled) @as(i32, c.MF_ENABLED) else @as(i32, c.MF_GRAYED)); + _ = c.EnableMenuItem(c.GetMenu(hwnd), @intFromEnum(command), flags); +} + +fn setChecked(hwnd: c.HWND, command: Command, checked: bool) void { + const flags: c.UINT = @intCast(@as(i32, c.MF_BYCOMMAND) | + if (checked) @as(i32, c.MF_CHECKED) else @as(i32, c.MF_UNCHECKED)); + _ = c.CheckMenuItem(c.GetMenu(hwnd), @intFromEnum(command), flags); +} + +fn append(menu: c.HMENU, text: []const u8, id: usize) void { + const wide = toWideZ(std.heap.c_allocator, text) catch return; + defer std.heap.c_allocator.free(wide); + _ = c.AppendMenuW(menu, c.MF_STRING, id, wide.ptr); +} + +fn appendPopup(menu: c.HMENU, text: []const u8, popup: c.HMENU) void { + const wide = toWideZ(std.heap.c_allocator, text) catch return; + defer std.heap.c_allocator.free(wide); + _ = c.AppendMenuW(menu, c.MF_POPUP | c.MF_STRING, @intFromPtr(popup), wide.ptr); +} + +fn toWideZ(allocator: std.mem.Allocator, text: []const u8) ![:0]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, text); + defer allocator.free(raw); + const wide = try allocator.allocSentinel(u16, raw.len, 0); + @memcpy(wide[0..raw.len], raw); + return wide; +} + +fn separator(menu: c.HMENU) void { + _ = c.AppendMenuW(menu, c.MF_SEPARATOR, 0, null); +} + +fn createAccelerators() c.HACCEL { + var entries = [_]c.ACCEL{ + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'O', .cmd = @intFromEnum(Command.open_folder) }, + .{ .fVirt = c.FCONTROL | c.FSHIFT | c.FVIRTKEY, .key = 'W', .cmd = @intFromEnum(Command.worktrees) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'J', .cmd = @intFromEnum(Command.jump_loop) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = c.VK_TAB, .cmd = @intFromEnum(Command.review_attention) }, + .{ .fVirt = c.FVIRTKEY, .key = c.VK_TAB, .cmd = @intFromEnum(Command.next_loop) }, + .{ .fVirt = c.FSHIFT | c.FVIRTKEY, .key = c.VK_TAB, .cmd = @intFromEnum(Command.previous_loop) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'N', .cmd = @intFromEnum(Command.create_node) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'S', .cmd = @intFromEnum(Command.stop_loop) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'T', .cmd = @intFromEnum(Command.new_tab) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'W', .cmd = @intFromEnum(Command.close_tab) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 'D', .cmd = @intFromEnum(Command.split_right) }, + .{ .fVirt = c.FCONTROL | c.FSHIFT | c.FVIRTKEY, .key = 'D', .cmd = @intFromEnum(Command.split_down) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = c.VK_NEXT, .cmd = @intFromEnum(Command.next_tab) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = c.VK_PRIOR, .cmd = @intFromEnum(Command.previous_tab) }, + .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 0xBC, .cmd = @intFromEnum(Command.settings) }, + }; + return c.CreateAcceleratorTableW(&entries, entries.len); +} + +test "native menu exposes the parity command groups" { + try std.testing.expectEqual(Command.open_folder, commandFromId(4101).?); + try std.testing.expectEqual(Command.split_right, commandFromId(4303).?); + try std.testing.expectEqual(Command.about, commandFromId(4501).?); + try std.testing.expectEqual(@as(?Command, null), commandFromId(9999)); +} + +test "native menu labels are NUL terminated UTF-16" { + const wide = try toWideZ(std.testing.allocator, "Clone Repository…"); + defer std.testing.allocator.free(wide); + try std.testing.expectEqual(@as(u16, 0), wide[wide.len]); + try std.testing.expect(wide.len > "Clone Repository".len); +} + + +fn windowFromHandle(hwnd: c.HWND) ?*Window { + const raw = c.GetWindowLongPtrW(hwnd, c.GWLP_USERDATA); + if (raw == 0) return null; + return @ptrFromInt(@as(usize, @bitCast(raw))); +} + +fn registerClass(instance: c.HINSTANCE) !void { + var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + window_class.lpfnWndProc = @ptrCast(&windowProc); + window_class.hInstance = instance; + window_class.lpszClassName = class_name.ptr; + window_class.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + if (c.RegisterClassW(&window_class) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) { + return error.WindowClassRegistrationFailed; + } +} + +fn windowProc( + hwnd: c.HWND, + message: c.UINT, + wparam: c.WPARAM, + lparam: c.LPARAM, +) callconv(.winapi) c.LRESULT { + var window = windowFromHandle(hwnd); + if (message == c.WM_NCCREATE) { + const create = @as(*const c.CREATESTRUCTW, @ptrFromInt(@as(usize, @bitCast(lparam)))); + window = @ptrCast(@alignCast(create.lpCreateParams)); + if (window) |value| { + value.hwnd = hwnd; + _ = c.SetWindowLongPtrW(hwnd, c.GWLP_USERDATA, @intCast(@intFromPtr(value))); + } + } + const value = window orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); + var result: c.LRESULT = 0; + if (value.callback) |callback| { + if (callback(value.context, hwnd, message, wparam, lparam, &result)) { + if (message == c.WM_NCDESTROY) _ = c.SetWindowLongPtrW(hwnd, c.GWLP_USERDATA, 0); + return result; + } + } + result = c.DefWindowProcW(hwnd, message, wparam, lparam); + if (message == c.WM_NCDESTROY) _ = c.SetWindowLongPtrW(hwnd, c.GWLP_USERDATA, 0); + return result; +} diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig new file mode 100644 index 00000000..2b222d91 --- /dev/null +++ b/graphcode-windows/src/NativeForms.zig @@ -0,0 +1,1431 @@ +const std = @import("std"); +const Forms = @import("Forms.zig"); +const WorktreeStatus = @import("WorktreeStatus.zig"); +const c = @import("Win32.zig").c; + +const DialogState = struct { + allocator: std.mem.Allocator, + kind: Kind, + parent: c.HWND, + result: bool = false, + closed: bool = false, + scroll_offset: i32 = 0, + checks: [3]c.HWND = .{ null, null, null }, + labels: [20]c.HWND = .{null} ** 20, + helps: [20]c.HWND = .{null} ** 20, + edits: [20]c.HWND = .{ null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null }, + input_kinds: [20]InputKind = .{.edit} ** 20, + choice_groups: [20]ChoiceGroup = .{.none} ** 20, + visible: [20]bool = .{false} ** 20, + field_count: usize = 0, + intro: c.HWND = null, + validation: c.HWND = null, + values: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, + initial_values: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, + display_labels: [20][]u8 = .{ &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{}, &.{} }, + policy: WorktreeStatus.Policy = .{}, + edge_endpoints: []const EdgeEndpoint = &.{}, + lock_edge_endpoints: bool = true, +}; + +const Kind = enum { node, edge, update, settings, jump, worktree_policy, worktree_sweep }; +const InputKind = enum { edit, readonly, combo, checkbox }; +const ChoiceGroup = enum { none, loop_type, backend, model_tier, metric_direction, optional_metric_direction, edge_kind, edge_condition, transform }; +const Choice = struct { label: []const u8, value: []const u8 }; +pub const EdgeEndpoint = struct { id: []const u8, title: []const u8 }; +pub const WorktreeSweepResult = struct { + selected: [20]bool = .{false} ** 20, + count: usize = 0, +}; +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm"); +const ok_id = 1; +const cancel_id = 2; +var active_state: bool = false; +var active_state_storage: DialogState = undefined; + +const ModalCommand = enum { submit, cancel, close, destroy }; + +const loop_type_choices = [_]Choice{ + .{ .label = "Turn-based — pause for review", .value = "turnBased" }, + .{ .label = "Time-based — repeat a prompt", .value = "timeBased" }, + .{ .label = "Goal-based — work toward done", .value = "goalBased" }, + .{ .label = "Proactive — design a nested workflow", .value = "proactive" }, +}; +const backend_choices = [_]Choice{ + .{ .label = "Use workspace default", .value = "" }, + .{ .label = "Claude Code", .value = "claudeCode" }, + .{ .label = "GitHub Copilot CLI", .value = "copilotCLI" }, + .{ .label = "OpenAI Codex", .value = "codex" }, +}; +const model_choices = [_]Choice{ + .{ .label = "Use agent default", .value = "" }, + .{ .label = "Fast", .value = "fast" }, + .{ .label = "Standard", .value = "standard" }, + .{ .label = "Capable", .value = "capable" }, +}; +const metric_direction_choices = [_]Choice{ + .{ .label = "Higher is better", .value = "maximize" }, + .{ .label = "Lower is better", .value = "minimize" }, +}; +const optional_metric_direction_choices = [_]Choice{ + .{ .label = "Leave unchanged", .value = "" }, + .{ .label = "Higher is better", .value = "maximize" }, + .{ .label = "Lower is better", .value = "minimize" }, +}; +const edge_kind_choices = [_]Choice{ + .{ .label = "Hand-off — continue execution", .value = "handoff" }, + .{ .label = "Message — store a message route", .value = "message" }, + .{ .label = "Spawn — start work in another project", .value = "spawn" }, +}; +const edge_condition_choices = [_]Choice{ + .{ .label = "Always", .value = "always" }, + .{ .label = "Only after success", .value = "onSuccess" }, + .{ .label = "Only after failure", .value = "onFailure" }, +}; +const transform_choices = [_]Choice{ + .{ .label = "Pass context unchanged", .value = "none" }, + .{ .label = "Apply a text template", .value = "template" }, + .{ .label = "Run a script", .value = "script" }, +}; + +fn choices(group: ChoiceGroup) []const Choice { + return switch (group) { + .loop_type => &loop_type_choices, + .backend => &backend_choices, + .model_tier => &model_choices, + .metric_direction => &metric_direction_choices, + .optional_metric_direction => &optional_metric_direction_choices, + .edge_kind => &edge_kind_choices, + .edge_condition => &edge_condition_choices, + .transform => &transform_choices, + .none => &.{}, + }; +} + +fn choiceIndex(group: ChoiceGroup, value: []const u8) usize { + const normalized = if (group == .loop_type and std.mem.eql(u8, value, "composite")) "proactive" else value; + for (choices(group), 0..) |choice, index| { + if (std.mem.eql(u8, choice.value, normalized)) return index; + } + return 0; +} + +fn choiceValue(group: ChoiceGroup, index: usize, previous: []const u8) []const u8 { + const options = choices(group); + if (index >= options.len) return previous; + if (group == .loop_type and index == 3 and std.mem.eql(u8, previous, "composite")) + return previous; + return options[index].value; +} + +fn applyModalCommand(state: *DialogState, command: ModalCommand) void { + switch (command) { + .submit => state.result = true, + .cancel, .close => state.result = false, + .destroy => {}, + } + state.closed = true; +} + +pub fn node( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.NodeDraft, +) !?Forms.NodeDraft { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .node, .parent = parent }; + defer { + freeValues(state); + allocator.destroy(state); + } + state.values[0] = try allocator.dupe(u8, initial.title); + state.values[1] = try allocator.dupe(u8, initial.loop_type); + state.values[2] = try allocator.dupe(u8, initial.check_description); + state.values[3] = try allocator.dupe(u8, initial.trigger_prompt); + state.values[4] = try allocator.dupe(u8, initial.first_instruction); + state.values[5] = try allocator.dupe(u8, if (initial.pauses_before_writes_only) "true" else "false"); + state.values[6] = try allocator.dupe(u8, initial.goal_summary); + state.values[7] = try allocator.dupe(u8, initial.goal_predicate); + state.values[8] = try dupFloatText(allocator, initial.poll_interval_seconds); + state.values[9] = try dupOptionalFloatText(allocator, initial.stall_after_seconds); + state.values[10] = try allocator.dupe(u8, initial.metric_command); + state.values[11] = try allocator.dupe(u8, initial.metric_direction); + state.values[12] = try allocator.dupe(u8, initial.backend orelse ""); + state.values[13] = try allocator.dupe(u8, initial.model_tier); + state.values[14] = try allocator.dupe(u8, initial.worktree_repository); + state.values[15] = try allocator.dupe(u8, initial.worktree_id); + state.values[16] = try allocator.dupe(u8, initial.worktree_path); + state.values[17] = try allocator.dupe(u8, initial.worktree_branch); + state.values[18] = try allocator.dupe(u8, initial.subgraph_json); + state.values[19] = try allocator.dupe(u8, initial.created_by); + for (0..20) |index| state.initial_values[index] = try allocator.dupe(u8, state.values[index]); + if (!(try show(state, "Create or edit node", &.{}))) return null; + return try buildNodeDraft(allocator, state.values, initial); +} + +fn buildNodeDraft( + allocator: std.mem.Allocator, + values: [20][]u8, + initial: Forms.NodeDraft, +) !Forms.NodeDraft { + const goal_based = std.mem.eql(u8, values[1], "goalBased"); + const poll_interval = if (goal_based) + parseRequiredFloat(values[8]) catch return error.InvalidNumericInput + else + initial.poll_interval_seconds; + const stall_after = if (goal_based) + parseOptionalFloat(values[9]) catch return error.InvalidNumericInput + else + initial.stall_after_seconds; + var result = Forms.NodeDraft{ .title = &.{}, .loop_type = &.{}, .check_description = &.{}, .trigger_prompt = &.{}, .first_instruction = &.{}, .goal_summary = &.{}, .goal_predicate = &.{}, .metric_command = &.{}, .metric_direction = &.{}, .model_tier = &.{}, .worktree_repository = &.{}, .worktree_id = &.{}, .worktree_path = &.{}, .worktree_branch = &.{}, .subgraph_json = &.{}, .created_by = &.{} }; + errdefer result.deinit(allocator); + result.title = try allocator.dupe(u8, values[0]); + result.loop_type = try allocator.dupe(u8, values[1]); + result.check_description = try allocator.dupe(u8, values[2]); + result.trigger_prompt = try allocator.dupe(u8, values[3]); + result.first_instruction = try allocator.dupe(u8, values[4]); + result.pauses_before_writes_only = std.mem.eql(u8, values[5], "true"); + result.goal_summary = try allocator.dupe(u8, values[6]); + result.goal_predicate = try allocator.dupe(u8, values[7]); + result.poll_interval_seconds = poll_interval; + result.stall_after_seconds = stall_after; + result.metric_command = try allocator.dupe(u8, values[10]); + result.metric_direction = try allocator.dupe(u8, values[11]); + result.backend = if (std.mem.trim(u8, values[12], " \t\r\n").len == 0) null else try allocator.dupe(u8, values[12]); + result.model_tier = try allocator.dupe(u8, values[13]); + result.worktree_repository = try allocator.dupe(u8, initial.worktree_repository); + result.worktree_id = try allocator.dupe(u8, initial.worktree_id); + result.worktree_path = try allocator.dupe(u8, initial.worktree_path); + result.worktree_branch = try allocator.dupe(u8, initial.worktree_branch); + result.subgraph_json = try allocator.dupe(u8, initial.subgraph_json); + result.created_by = try allocator.dupe(u8, initial.created_by); + result.claude_permissions = initial.claude_permissions; + result.copilot_permissions = initial.copilot_permissions; + result.briefing_enabled = initial.briefing_enabled; + result.activity_enabled = initial.activity_enabled; + try Forms.validateNode(result); + return result; +} + +pub fn edge( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.EdgeDraft, +) !?Forms.EdgeDraft { + return edgeWithEndpoints(parent, allocator, initial, &.{}, true); +} + +pub fn edgeWithEndpoints( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.EdgeDraft, + endpoints: []const EdgeEndpoint, + lock_endpoints: bool, +) !?Forms.EdgeDraft { + const state = try allocator.create(DialogState); + state.* = .{ + .allocator = allocator, + .kind = .edge, + .parent = parent, + .edge_endpoints = endpoints, + .lock_edge_endpoints = lock_endpoints, + }; + defer { + freeValues(state); + allocator.destroy(state); + } + + state.values[0] = try allocator.dupe(u8, initial.from); + state.values[1] = try allocator.dupe(u8, initial.to); + state.values[2] = try allocator.dupe(u8, initial.kind); + state.values[3] = try allocator.dupe(u8, initial.condition); + state.values[4] = try allocator.dupe(u8, initial.transform_kind); + state.values[5] = try allocator.dupe(u8, initial.transform_value); + state.values[6] = try allocator.dupe(u8, initial.cycle_until); + state.values[7] = try dupOptionalIntText(allocator, initial.cycle_max_iterations); + state.values[8] = try dupOptionalIntText(allocator, initial.cycle_stop_after_passes); + state.values[9] = try allocator.dupe(u8, initial.spawn_target_project_path); + if (!(try show(state, "Create or edit edge", &.{}))) return null; + return try buildEdgeDraft(allocator, state.values); +} + +fn buildEdgeDraft(allocator: std.mem.Allocator, values: [20][]u8) !Forms.EdgeDraft { + const cycle_max = parseOptionalInt(values[7]) catch return error.InvalidNumericInput; + const cycle_stop = parseOptionalInt(values[8]) catch return error.InvalidNumericInput; + var result = Forms.EdgeDraft{ .from = &.{}, .to = &.{}, .kind = &.{}, .condition = &.{}, .transform_kind = &.{}, .transform_value = &.{}, .cycle_until = &.{}, .spawn_target_project_path = &.{} }; + errdefer result.deinit(allocator); + result.from = try allocator.dupe(u8, values[0]); + result.to = try allocator.dupe(u8, values[1]); + result.kind = try allocator.dupe(u8, values[2]); + result.condition = try allocator.dupe(u8, values[3]); + result.transform_kind = try allocator.dupe(u8, values[4]); + result.transform_value = try allocator.dupe(u8, values[5]); + result.cycle_until = try allocator.dupe(u8, values[6]); + result.cycle_max_iterations = cycle_max; + result.cycle_stop_after_passes = cycle_stop; + result.spawn_target_project_path = try allocator.dupe(u8, values[9]); + try Forms.validateEdge(result); + return result; +} + +pub fn update( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.NodeUpdate, +) !?Forms.NodeUpdate { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .update, .parent = parent }; + defer { + freeValues(state); + allocator.destroy(state); + } + state.values[0] = try dupOptional(allocator, initial.goal_summary); + state.values[1] = try dupOptional(allocator, initial.goal_predicate); + state.values[2] = try dupFloat(allocator, initial.poll_interval_seconds); + state.values[3] = try dupFloat(allocator, initial.stall_after_seconds); + state.values[4] = try dupOptional(allocator, initial.metric_command); + state.values[5] = try dupOptional(allocator, initial.metric_direction); + state.values[6] = try dupOptional(allocator, initial.trigger_prompt); + state.values[7] = try dupOptional(allocator, initial.check_description); + state.values[8] = try dupOptional(allocator, initial.model_tier); + for (0..9) |index| state.initial_values[index] = try allocator.dupe(u8, state.values[index]); + if (!(try show(state, "Update node", &.{}))) return null; + const poll_interval = try changedFloat(state.values[2], initial.poll_interval_seconds, false); + const stall_after = try changedFloat(state.values[3], initial.stall_after_seconds, true); + var result = Forms.NodeUpdate{}; + errdefer result.deinit(allocator); + result.goal_summary = try changedOptional(allocator, state.values[0], initial.goal_summary, false); + result.goal_predicate = try changedOptional(allocator, state.values[1], initial.goal_predicate, true); + result.poll_interval_seconds = poll_interval; + result.stall_after_seconds = stall_after; + result.metric_command = try changedOptional(allocator, state.values[4], initial.metric_command, true); + result.metric_direction = try changedOptional(allocator, state.values[5], initial.metric_direction, false); + result.trigger_prompt = try changedOptional(allocator, state.values[6], initial.trigger_prompt, true); + result.check_description = try changedOptional(allocator, state.values[7], initial.check_description, true); + result.model_tier = try changedOptional(allocator, state.values[8], initial.model_tier, false); + Forms.validateNodeUpdate(result) catch return error.InvalidNodeUpdate; + return result; +} + +fn dupOptional(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { + return allocator.dupe(u8, value orelse ""); +} + +fn dupFloat(allocator: std.mem.Allocator, value: ?f64) ![]u8 { + return if (value) |number| std.fmt.allocPrint(allocator, "{d}", .{number}) else allocator.dupe(u8, ""); +} + +fn dupFloatText(allocator: std.mem.Allocator, value: f64) ![]u8 { + return std.fmt.allocPrint(allocator, "{d}", .{value}); +} + +fn dupOptionalFloatText(allocator: std.mem.Allocator, value: ?f64) ![]u8 { + return if (value) |number| std.fmt.allocPrint(allocator, "{d}", .{number}) else allocator.dupe(u8, ""); +} + +fn dupOptionalIntText(allocator: std.mem.Allocator, value: ?i64) ![]u8 { + return if (value) |number| std.fmt.allocPrint(allocator, "{d}", .{number}) else allocator.dupe(u8, ""); +} + +fn parseRequiredFloat(value: []const u8) !f64 { + return std.fmt.parseFloat(f64, std.mem.trim(u8, value, " \t\r\n")); +} + +fn parseOptionalFloat(value: []const u8) !?f64 { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + if (trimmed.len == 0) return null; + return try std.fmt.parseFloat(f64, trimmed); +} + +fn parseOptionalInt(value: []const u8) !?i64 { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + if (trimmed.len == 0) return null; + return try std.fmt.parseInt(i64, trimmed, 10); +} + +fn optionalValue(allocator: std.mem.Allocator, value: []const u8) !?[]u8 { + if (std.mem.trim(u8, value, " \t\r\n").len == 0) return null; + return try allocator.dupe(u8, value); +} + +fn changedOptional( + allocator: std.mem.Allocator, + value: []const u8, + initial: ?[]const u8, + allow_clear: bool, +) !?[]u8 { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + const original = initial orelse ""; + if (std.mem.eql(u8, trimmed, std.mem.trim(u8, original, " \t\r\n"))) return null; + if (trimmed.len == 0 and !allow_clear) return null; + return try allocator.dupe(u8, if (trimmed.len == 0) "" else value); +} + +fn changedFloat(value: []const u8, initial: ?f64, clear_blank: bool) !?f64 { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + if (trimmed.len == 0) { + if (clear_blank and initial != null) return 0; + return null; + } + const parsed = try std.fmt.parseFloat(f64, trimmed); + if (initial) |original| if (parsed == original) return null; + return parsed; +} + +pub fn settings( + parent: c.HWND, + allocator: std.mem.Allocator, + initial: Forms.Settings, +) !?Forms.Settings { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .settings, .parent = parent }; + defer { + freeValues(state); + allocator.destroy(state); + } + + state.values[0] = try allocator.dupe(u8, initial.daemon_pipe); + state.values[1] = try allocator.dupe(u8, initial.support_directory); + if (!(try show(state, "GraphCode settings", &.{ "Daemon pipe override", "Support directory" }))) return null; + return .{ + .daemon_pipe = try allocator.dupe(u8, state.values[0]), + .support_directory = try allocator.dupe(u8, state.values[1]), + .reconnect_automatically = initial.reconnect_automatically, + }; +} + +pub fn jump(parent: c.HWND, allocator: std.mem.Allocator, initial: []const u8) !?[]u8 { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .jump, .parent = parent }; + defer { + freeValues(state); + allocator.destroy(state); + } + + state.values[0] = try allocator.dupe(u8, initial); + if (!(try show(state, "Jump to loop", &.{"Loop title or ID"}))) return null; + return try allocator.dupe(u8, state.values[0]); +} + +pub fn worktreePolicy(parent: c.HWND, allocator: std.mem.Allocator, initial: WorktreeStatus.Policy) !?WorktreeStatus.Policy { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .worktree_policy, .parent = parent, .policy = initial }; + defer { + freeValues(state); + allocator.destroy(state); + } + + state.values[0] = try std.fmt.allocPrint(allocator, "{d}", .{initial.notice_size_gb}); + state.values[1] = try std.fmt.allocPrint(allocator, "{d}", .{initial.notice_count}); + if (!(try show(state, "Project Settings", &.{}))) return null; + return state.policy; +} + +pub fn worktreeSweep( + parent: c.HWND, + allocator: std.mem.Allocator, + project_name: []const u8, + entries: []const WorktreeStatus.Entry, +) !?WorktreeSweepResult { + const state = try allocator.create(DialogState); + state.* = .{ .allocator = allocator, .kind = .worktree_sweep, .parent = parent }; + defer { + freeValues(state); + allocator.destroy(state); + } + const count = @min(entries.len, state.values.len); + for (entries[0..count], 0..) |entry, index| { + const tier = if (WorktreeStatus.decision(entry) == .reclaimable) + "SAFE TO REMOVE" + else if (entry.bound_running or entry.primary) + "IN USE" + else + "LOOK BEFORE REMOVING"; + const branch = if (entry.branch.len != 0) entry.branch else entry.path; + state.display_labels[index] = try std.fmt.allocPrint( + allocator, + "{s}: {s} - {s}", + .{ tier, branch, WorktreeStatus.failureReasonText(entry) }, + ); + state.values[index] = try allocator.dupe(u8, if (WorktreeStatus.decision(entry) == .reclaimable) "true" else "false"); + state.initial_values[index] = try allocator.dupe(u8, state.values[index]); + state.input_kinds[index] = .checkbox; + state.visible[index] = true; + } + state.field_count = count; + const title = try std.fmt.allocPrint(allocator, "Worktrees - {s}", .{project_name}); + defer allocator.free(title); + if (!(try show(state, title, &.{}))) return null; + var result = WorktreeSweepResult{ .count = count }; + for (0..count) |index| result.selected[index] = std.mem.eql(u8, state.values[index], "true"); + return result; +} + +fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !bool { + _ = labels; + registerClass() catch return error.FormClassRegistrationFailed; + const wide_title = try utf8ToWideZ(state.allocator, title); + defer state.allocator.free(wide_title); + active_state_storage = state.*; + active_state_storage.closed = false; + active_state_storage.result = false; + active_state = true; + const screen_height = c.GetSystemMetrics(c.SM_CYSCREEN); + const dialog_height: i32 = if (state.kind == .worktree_policy) 430 else @max(320, @min(700, screen_height - 96)); + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + class_name.ptr, + wide_title.ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU | c.WS_VSCROLL, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 580, + dialog_height, + state.parent, + null, + c.GetModuleHandleW(null), + @ptrCast(state), + ) orelse { + active_state = false; + return error.FormCreationFailed; + }; + _ = c.EnableWindow(state.parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + var message: c.MSG = undefined; + var quit_code: ?c.WPARAM = null; + while (!active_state_storage.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + active_state_storage.closed = true; + if (code == 0) quit_code = message.wParam; + break; + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + // Destroy the modal window from the owner thread after dispatch returns. + // Calling DestroyWindow from the window procedure can violate the C + // callback handle alignment contract on some Zig/Win32 combinations. + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(state.parent, 1); + _ = c.SetActiveWindow(state.parent); + state.* = active_state_storage; + active_state = false; + if (quit_code) |value| c.PostQuitMessage(@intCast(value)); + return state.result; +} + +fn registerClass() !void { + var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + window_class.lpfnWndProc = @ptrCast(&windowProc); + window_class.hInstance = c.GetModuleHandleW(null); + window_class.lpszClassName = class_name.ptr; + window_class.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + if (c.RegisterClassW(&window_class) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.ClassRegistrationFailed; +} + +fn configureFields(state: *DialogState) void { + state.field_count = switch (state.kind) { + .node => 14, + .edge => 10, + .update => 9, + .settings => 2, + .jump => 1, + .worktree_policy => 0, + .worktree_sweep => state.field_count, + }; + for (0..state.field_count) |index| state.visible[index] = true; + switch (state.kind) { + .node => { + state.input_kinds[1] = .combo; + state.choice_groups[1] = .loop_type; + state.input_kinds[5] = .checkbox; + state.input_kinds[11] = .combo; + state.choice_groups[11] = .metric_direction; + state.input_kinds[12] = .combo; + state.choice_groups[12] = .backend; + state.input_kinds[13] = .combo; + state.choice_groups[13] = .model_tier; + }, + .edge => { + state.input_kinds[0] = if (state.lock_edge_endpoints) .readonly else .combo; + state.input_kinds[1] = if (state.lock_edge_endpoints) .readonly else .combo; + state.input_kinds[2] = .combo; + state.choice_groups[2] = .edge_kind; + state.input_kinds[3] = .combo; + state.choice_groups[3] = .edge_condition; + state.input_kinds[4] = .combo; + state.choice_groups[4] = .transform; + }, + .update => { + state.input_kinds[5] = .combo; + state.choice_groups[5] = .optional_metric_direction; + state.input_kinds[8] = .combo; + state.choice_groups[8] = .model_tier; + }, + else => {}, + } + updateConditionalVisibility(state); +} + +fn updateConditionalVisibility(state: *DialogState) void { + switch (state.kind) { + .node => { + const loop_type = state.values[1]; + const turn = std.mem.eql(u8, loop_type, "turnBased"); + const timed = std.mem.eql(u8, loop_type, "timeBased"); + const goal = std.mem.eql(u8, loop_type, "goalBased"); + state.visible[2] = turn; + state.visible[3] = timed; + state.visible[4] = turn; + state.visible[5] = turn; + for (6..12) |index| state.visible[index] = goal; + }, + .edge => { + state.visible[5] = !std.mem.eql(u8, state.values[4], "none"); + state.visible[9] = std.mem.eql(u8, state.values[2], "spawn"); + }, + else => {}, + } +} + +fn formIntro(kind: Kind) []const u8 { + return switch (kind) { + .node => "Choose how this loop works. Only the settings that affect that loop type are shown; existing internal graph metadata is preserved.", + .edge => "Choose or confirm two loops, then describe how work moves between them.", + .update => "Change only the fields you intend to update. Blank optional fields keep their documented clear-or-unchanged behavior.", + .worktree_sweep => "Safe rows start selected. Blocked rows remain visible for review. Only committed, pushed, landed, unbound worktrees are eligible; branches remain recoverable from reflog.", + else => "", + }; +} + +fn fieldLabel(kind: Kind, index: usize) []const u8 { + const node_labels = [_][]const u8{ + "Name (optional)", "How should this loop run?", "What are you checking for? (optional)", + "What should it do each time?", "First instruction", "Pause only before writing files", + "What does done look like?", "Done check command (optional)", "Check every (seconds)", + "Declare stalled after (seconds, optional)", "Progress metric command (optional)", "When is the metric better?", + "Agent", "Model", + }; + const edge_labels = [_][]const u8{ + "Source loop identity", "Target loop identity", "What should this connection do?", "When does it fire?", + "What context should cross?", "Template or script", "Stop early command (optional)", "Maximum passes (optional)", + "Flat metric passes before stopping (optional)", "Target project path", + }; + const update_labels = [_][]const u8{ + "Goal summary (blank leaves unchanged)", "Goal predicate (blank clears)", "Poll interval seconds", + "Stall after seconds (blank clears)", "Metric command (blank clears)", "Metric direction", + "Trigger prompt (blank clears)", "Check description (blank clears)", "Model tier", + }; + const settings_labels = [_][]const u8{ "Daemon pipe override", "Support directory" }; + return switch (kind) { + .node => node_labels[index], + .edge => edge_labels[index], + .update => update_labels[index], + .settings => settings_labels[index], + .jump => "Loop title or ID", + .worktree_policy, .worktree_sweep => "", + }; +} + +fn stateFieldLabel(state: *const DialogState, index: usize) []const u8 { + if (state.kind == .worktree_sweep and state.display_labels[index].len != 0) + return state.display_labels[index]; + return fieldLabel(state.kind, index); +} + +fn fieldHelp(kind: Kind, index: usize) []const u8 { + if (kind == .node) return switch (index) { + 2 => "Shown at each pause as the bar the loop is aiming for.", + 3 => "This prompt is run whenever the time-based trigger fires.", + 4 => "The session starts with this task instead of opening without direction.", + 5 => "When unchecked, the loop pauses after every turn.", + 6 => "Say it in your own words; the loop works toward this outcome.", + 7 => "Exit 0 means done.", + 10 => "A command that prints one number.", + 12 => "Use the workspace default unless this loop needs a specific agent.", + else => "", + }; + if (kind == .edge) return switch (index) { + 0, 1 => "Choose a loop by title; its stable graph identity is preserved.", + 2 => "Hand-offs unblock, messages deliver into a live session, and spawns instantiate work.", + 5 => "Required when a template or script transform is selected.", + 6 => "Exit 0 ends a repeated hand-off early.", + 7 => "A positive bound prevents an unbounded cycle.", + 8 => "Requires a progress metric on the source loop.", + else => "", + }; + return ""; +} + +fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!active_state) return c.DefWindowProcW(hwnd, message, wparam, lparam); + const safe_hwnd: c.HWND = @ptrFromInt(@intFromPtr(hwnd.?)); + const value = &active_state_storage; + switch (message) { + c.WM_CREATE => { + configureFields(value); + if (value.kind == .worktree_policy) { + createStatic(safe_hwnd, value, "Project Settings", 18, 14, 530, 24, &value.intro); + var unused: c.HWND = null; + createStatic(safe_hwnd, value, "When a loop resolves and its branch has landed", 18, 48, 530, 20, &unused); + createPolicyRadio(safe_hwnd, value, "Remove: automatically remove safe landed worktrees.", 0, 74); + createPolicyRadio(safe_hwnd, value, "Ask: offer Reclaim / Keep on the resolved loop.", 1, 104); + createPolicyRadio(safe_hwnd, value, "Keep: leave worktrees until Worktrees is opened.", 2, 134); + createStatic(safe_hwnd, value, "Only landed, clean, pushed, unbound worktrees are ever eligible.", 18, 170, 530, 32, &unused); + createStatic(safe_hwnd, value, "Mention worktrees when this project passes either threshold", 18, 210, 530, 20, &unused); + createPolicyEdit(safe_hwnd, value, 0, 18, 238, 70); + createStatic(safe_hwnd, value, "GB", 94, 242, 30, 20, &unused); + createPolicyEdit(safe_hwnd, value, 1, 145, 238, 70); + createStatic(safe_hwnd, value, "worktrees", 221, 242, 90, 20, &unused); + createStatic(safe_hwnd, value, "The notice removes nothing; it only surfaces cleanup work.", 18, 276, 530, 32, &unused); + createStatic(safe_hwnd, value, "", 18, 316, 360, 34, &value.validation); + } else { + createStatic(safe_hwnd, value, formIntro(value.kind), 18, 12, 530, 34, &value.intro); + for (0..value.field_count) |index| createField(safe_hwnd, value, index); + createStatic(safe_hwnd, value, "", 18, 0, 320, 34, &value.validation); + layoutForm(safe_hwnd, value); + } + var client: c.RECT = undefined; + _ = c.GetClientRect(safe_hwnd, &client); + createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); + createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); + return 0; + }, + c.WM_SIZE => { + var client: c.RECT = undefined; + _ = c.GetClientRect(safe_hwnd, &client); + _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(ok_id)), 478, client.bottom - 38, 70, 26, 1); + _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(cancel_id)), 393, client.bottom - 38, 70, 26, 1); + if (value.validation != null) _ = c.MoveWindow(value.validation, 18, client.bottom - 42, 360, 34, 1); + if (value.kind != .worktree_policy) layoutForm(safe_hwnd, value); + updateScrollBar(safe_hwnd, value); + return 0; + }, + c.WM_VSCROLL => { + const command: u16 = @truncate(wparam); + if (command == c.SB_THUMBTRACK or command == c.SB_THUMBPOSITION) { + var info: c.SCROLLINFO = std.mem.zeroes(c.SCROLLINFO); + info.cbSize = @sizeOf(c.SCROLLINFO); + info.fMask = c.SIF_TRACKPOS; + if (c.GetScrollInfo(safe_hwnd, c.SB_VERT, &info) != 0) { + setScrollOffset(safe_hwnd, value, info.nTrackPos); + } + return 0; + } + const delta: i32 = switch (command) { + c.SB_LINEUP => -48, + c.SB_LINEDOWN => 48, + c.SB_PAGEUP => -@as(i32, @intCast(@max(48, clientHeight(safe_hwnd) - 60))), + c.SB_PAGEDOWN => @as(i32, @intCast(@max(48, clientHeight(safe_hwnd) - 60))), + c.SB_TOP => -100000, + c.SB_BOTTOM => 100000, + else => 0, + }; + scrollFields(safe_hwnd, value, delta); + return 0; + }, + c.WM_MOUSEWHEEL => { + const wheel_delta: i16 = @bitCast(@as(u16, @truncate(wparam >> 16))); + scrollFields(safe_hwnd, value, if (wheel_delta > 0) -48 else 48); + return 0; + }, + c.WM_COMMAND => { + const command = @as(u16, @truncate(wparam)); + const notification: u16 = @truncate(wparam >> 16); + if ((notification == c.EN_SETFOCUS or notification == c.CBN_SETFOCUS or notification == c.BN_SETFOCUS) and command >= 9100 and command < 9120) { + ensureControlVisible(safe_hwnd, value, command - 9100); + return 0; + } + if (notification == c.CBN_SELCHANGE and command >= 9100 and command < 9120) { + readValue(value, command - 9100); + updateConditionalVisibility(value); + setStaticText(value, value.validation, ""); + layoutForm(safe_hwnd, value); + return 0; + } + if ((notification == c.EN_CHANGE or notification == c.BN_CLICKED) and command >= 9100 and command < 9120) + setStaticText(value, value.validation, ""); + if (command == ok_id) { + readValues(value); + readPolicy(value); + if (validationReason(value)) |reason| { + setStaticText(value, value.validation, reason); + } else { + applyModalCommand(value, .submit); + } + return 0; + } + if (command == cancel_id) { + applyModalCommand(value, .cancel); + return 0; + } + }, + c.WM_CLOSE => { + applyModalCommand(value, .close); + return 0; + }, + c.WM_SETFOCUS => { + if (c.GetFocus()) |focused| { + for (0..20) |index| { + if (focused == value.edits[index]) { + ensureControlVisible(safe_hwnd, value, index); + break; + } + } + } + }, + c.WM_DESTROY => { + applyModalCommand(value, .destroy); + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn createStatic(hwnd: c.HWND, state: *DialogState, text: []const u8, x: i32, y: i32, width: i32, height: i32, output: *c.HWND) void { + const wide = utf8ToWideZ(state.allocator, text) catch return; + defer state.allocator.free(wide); + output.* = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, x, y, width, height, hwnd, null, c.GetModuleHandleW(null), null); +} + +fn isEndpointCombo(state: *const DialogState, index: usize) bool { + return state.kind == .edge and !state.lock_edge_endpoints and index < 2; +} + +fn endpointIndex(endpoints: []const EdgeEndpoint, value: []const u8) usize { + for (endpoints, 0..) |endpoint, index| { + if (std.mem.eql(u8, endpoint.id, value)) return index; + } + return 0; +} + +fn inputControlHeight(kind: InputKind) i32 { + return if (kind == .combo) 180 else 24; +} + +fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { + createStatic( + hwnd, + state, + if (state.input_kinds[index] == .checkbox) "" else stateFieldLabel(state, index), + 18, + 0, + 530, + 18, + &state.labels[index], + ); + const style: c.DWORD = @as(c.DWORD, @intCast(c.WS_CHILD)) | + @as(c.DWORD, @intCast(c.WS_VISIBLE)) | + @as(c.DWORD, @intCast(c.WS_TABSTOP)); + const input = switch (state.input_kinds[index]) { + .combo => c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("COMBOBOX").ptr, + null, + style | @as(c.DWORD, @intCast(c.CBS_DROPDOWNLIST)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)), + 18, + 0, + 530, + 180, + hwnd, + childId(9100 + index), + c.GetModuleHandleW(null), + null, + ), + .checkbox => blk: { + const wide = utf8ToWideZ(state.allocator, stateFieldLabel(state, index)) catch break :blk null; + defer state.allocator.free(wide); + break :blk c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + style | @as(c.DWORD, @intCast(c.BS_AUTOCHECKBOX)), + 18, + 0, + 530, + 24, + hwnd, + childId(9100 + index), + c.GetModuleHandleW(null), + null, + ); + }, + .edit, .readonly => c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + null, + style | @as(c.DWORD, @intCast(c.ES_AUTOHSCROLL)) | (if (state.input_kinds[index] == .readonly) @as(c.DWORD, @intCast(c.ES_READONLY)) else 0), + 18, + 0, + 530, + 24, + hwnd, + childId(9100 + index), + c.GetModuleHandleW(null), + null, + ), + } orelse return; + state.edits[index] = input; + switch (state.input_kinds[index]) { + .combo => { + if (isEndpointCombo(state, index)) { + for (state.edge_endpoints) |endpoint| { + const label = std.fmt.allocPrint(state.allocator, "{s} — {s}", .{ endpoint.title, endpoint.id }) catch continue; + defer state.allocator.free(label); + const wide = utf8ToWideZ(state.allocator, label) catch continue; + defer state.allocator.free(wide); + _ = c.SendMessageW(input, c.CB_ADDSTRING, 0, @intCast(@intFromPtr(wide.ptr))); + } + _ = c.SendMessageW(input, c.CB_SETCURSEL, endpointIndex(state.edge_endpoints, state.values[index]), 0); + } else { + for (choices(state.choice_groups[index])) |choice| { + const wide = utf8ToWideZ(state.allocator, choice.label) catch continue; + defer state.allocator.free(wide); + _ = c.SendMessageW(input, c.CB_ADDSTRING, 0, @intCast(@intFromPtr(wide.ptr))); + } + _ = c.SendMessageW(input, c.CB_SETCURSEL, choiceIndex(state.choice_groups[index], state.values[index]), 0); + } + }, + .checkbox => { + _ = c.SendMessageW(input, c.BM_SETCHECK, if (std.mem.eql(u8, state.values[index], "true")) c.BST_CHECKED else c.BST_UNCHECKED, 0); + if (state.kind == .worktree_sweep and !std.mem.eql(u8, state.initial_values[index], "true")) + _ = c.EnableWindow(input, 0); + }, + else => { + const wide = utf8ToWideZ(state.allocator, state.values[index]) catch return; + defer state.allocator.free(wide); + _ = c.SetWindowTextW(input, wide.ptr); + }, + } + createStatic(hwnd, state, fieldHelp(state.kind, index), 18, 0, 530, 18, &state.helps[index]); +} + +fn setStaticText(state: *DialogState, hwnd: c.HWND, text: []const u8) void { + if (hwnd == null) return; + const wide = utf8ToWideZ(state.allocator, text) catch return; + defer state.allocator.free(wide); + _ = c.SetWindowTextW(hwnd, wide.ptr); +} + +fn layoutForm(hwnd: c.HWND, state: *DialogState) void { + var row: i32 = 0; + for (0..state.field_count) |index| { + const shown = state.visible[index]; + const command = if (shown) c.SW_SHOW else c.SW_HIDE; + _ = c.ShowWindow(state.labels[index], command); + _ = c.ShowWindow(state.edits[index], command); + _ = c.ShowWindow(state.helps[index], command); + if (!shown) continue; + const y = 54 + row * 64 - state.scroll_offset; + _ = c.MoveWindow(state.labels[index], 18, y, 530, 18, 1); + _ = c.MoveWindow(state.edits[index], 18, y + 18, 530, inputControlHeight(state.input_kinds[index]), 1); + _ = c.MoveWindow(state.helps[index], 18, y + 43, 530, 18, 1); + row += 1; + } + updateScrollBar(hwnd, state); +} + +fn visibleRowFor(state: *const DialogState, target: usize) ?usize { + var row: usize = 0; + for (0..state.field_count) |index| { + if (!state.visible[index]) continue; + if (index == target) return row; + row += 1; + } + return null; +} + +fn visibleFieldCount(state: *const DialogState) usize { + var count: usize = 0; + for (state.visible[0..state.field_count]) |shown| if (shown) { + count += 1; + }; + return count; +} + +fn contentHeight(state: *const DialogState) i32 { + return @intCast(54 + visibleFieldCount(state) * 64 + 12); +} + +fn clientHeight(hwnd: c.HWND) i32 { + var rect: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &rect); + return rect.bottom; +} + +fn scrollFields(hwnd: c.HWND, state: *DialogState, requested: i32) void { + const viewport = clientHeight(hwnd); + const content = contentHeight(state); + const next = boundedScrollOffset(content, viewport, state.scroll_offset, requested); + setScrollOffsetValue(hwnd, state, next); +} + +fn setScrollOffset(hwnd: c.HWND, state: *DialogState, requested: i32) void { + const viewport = clientHeight(hwnd); + const content = contentHeight(state); + const next = std.math.clamp(requested, 0, @max(0, content - @max(120, viewport - 48))); + setScrollOffsetValue(hwnd, state, next); +} + +fn setScrollOffsetValue(hwnd: c.HWND, state: *DialogState, next: i32) void { + const delta = state.scroll_offset - next; + if (delta == 0) return; + state.scroll_offset = next; + layoutForm(hwnd, state); + updateScrollBar(hwnd, state); +} + +fn updateScrollBar(hwnd: c.HWND, state: *DialogState) void { + const viewport = clientHeight(hwnd); + const content = contentHeight(state); + const page: u32 = @intCast(@max(1, viewport - 48)); + const max_offset = @max(0, content - @as(i32, @intCast(page))); + state.scroll_offset = std.math.clamp(state.scroll_offset, 0, max_offset); + var info: c.SCROLLINFO = std.mem.zeroes(c.SCROLLINFO); + info.cbSize = @sizeOf(c.SCROLLINFO); + info.fMask = c.SIF_RANGE | c.SIF_PAGE | c.SIF_POS; + info.nMin = 0; + info.nMax = content; + info.nPage = page; + info.nPos = @intCast(state.scroll_offset); + _ = c.SetScrollInfo(hwnd, c.SB_VERT, &info, 1); +} + +fn ensureControlVisible(hwnd: c.HWND, state: *DialogState, index: usize) void { + const row = visibleRowFor(state, index) orelse return; + const viewport = clientHeight(hwnd); + const top: i32 = @intCast(54 + row * 64); + const bottom = top + 61; + const visible_top = state.scroll_offset; + const visible_bottom = state.scroll_offset + @max(1, viewport - 48); + if (top < visible_top) { + scrollFields(hwnd, state, top - visible_top); + } else if (bottom > visible_bottom) { + scrollFields(hwnd, state, bottom - visible_bottom); + } +} + +fn boundedScrollOffset(content: i32, viewport: i32, current: i32, requested: i32) i32 { + const max_offset = @max(0, content - @max(120, viewport - 48)); + return std.math.clamp(current + requested, 0, max_offset); +} + +fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32) void { + const wide = utf8ToWideZ(std.heap.c_allocator, text) catch return; + defer std.heap.c_allocator.free(wide); + const button_style: c.DWORD = @intCast(if (id == ok_id) c.BS_DEFPUSHBUTTON else c.BS_PUSHBUTTON); + const style: c.DWORD = @as(c.DWORD, @intCast(c.WS_CHILD)) | + @as(c.DWORD, @intCast(c.WS_VISIBLE)) | + @as(c.DWORD, @intCast(c.WS_TABSTOP)) | + button_style; + _ = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, style, x, y, 70, 26, hwnd, childId(id), c.GetModuleHandleW(null), null); +} + +fn createCheckBox(hwnd: c.HWND, state: *DialogState, text: []const u8, index: usize, y: i32) void { + const wide = utf8ToWideZ(state.allocator, text) catch return; + defer state.allocator.free(wide); + const check = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 18, y, 380, 24, hwnd, childId(9200 + index), c.GetModuleHandleW(null), null) orelse return; + state.checks[index] = check; + const selected = if (index == 0) state.policy.allow_reclaim else state.policy.confirm_each_reclaim; + _ = c.SendMessageW(check, c.BM_SETCHECK, if (selected) c.BST_CHECKED else c.BST_UNCHECKED, 0); +} + +fn createPolicyRadio(hwnd: c.HWND, state: *DialogState, text: []const u8, index: usize, y: i32) void { + const wide = utf8ToWideZ(state.allocator, text) catch return; + defer state.allocator.free(wide); + const style: c.DWORD = @as(c.DWORD, @intCast(c.WS_CHILD)) | + @as(c.DWORD, @intCast(c.WS_VISIBLE)) | + @as(c.DWORD, @intCast(c.WS_TABSTOP)) | + @as(c.DWORD, @intCast(c.BS_AUTORADIOBUTTON)) | + (if (index == 0) @as(c.DWORD, @intCast(c.WS_GROUP)) else 0); + const radio = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + style, + 18, + y, + 530, + 24, + hwnd, + childId(9200 + index), + c.GetModuleHandleW(null), + null, + ) orelse return; + state.checks[index] = radio; + const selected_index: usize = switch (state.policy.effectiveResolveAction()) { + .remove => 0, + .ask => 1, + .keep, .legacy => 2, + }; + _ = c.SendMessageW(radio, c.BM_SETCHECK, if (selected_index == index) c.BST_CHECKED else c.BST_UNCHECKED, 0); +} + +fn createPolicyEdit(hwnd: c.HWND, state: *DialogState, index: usize, x: i32, y: i32, width: i32) void { + const edit = c.CreateWindowExW( + c.WS_EX_CLIENTEDGE, + std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, + null, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL | c.ES_NUMBER, + x, + y, + width, + 24, + hwnd, + childId(9100 + index), + c.GetModuleHandleW(null), + null, + ) orelse return; + state.edits[index] = edit; + const wide = utf8ToWideZ(state.allocator, state.values[index]) catch return; + defer state.allocator.free(wide); + _ = c.SetWindowTextW(edit, wide.ptr); +} + +fn childId(value: usize) c.HMENU { + @setRuntimeSafety(false); + return @ptrFromInt(value); +} + +fn readValues(state: *DialogState) void { + for (0..state.field_count) |index| readValue(state, index); +} + +fn readValue(state: *DialogState, index: usize) void { + if (state.edits[index] == null) return; + switch (state.input_kinds[index]) { + .combo => { + const selected = c.SendMessageW(state.edits[index], c.CB_GETCURSEL, 0, 0); + if (selected < 0) return; + const selected_index: usize = @intCast(selected); + const next = if (isEndpointCombo(state, index) and selected_index < state.edge_endpoints.len) + state.edge_endpoints[selected_index].id + else + choiceValue(state.choice_groups[index], selected_index, state.values[index]); + const value = state.allocator.dupe(u8, next) catch return; + state.allocator.free(state.values[index]); + state.values[index] = value; + }, + .checkbox => { + const selected = c.SendMessageW(state.edits[index], c.BM_GETCHECK, 0, 0) == c.BST_CHECKED; + const value = state.allocator.dupe(u8, if (selected) "true" else "false") catch return; + state.allocator.free(state.values[index]); + state.values[index] = value; + }, + .edit, .readonly => { + var buffer: [4096]u16 = undefined; + const length = c.GetWindowTextW(state.edits[index], &buffer, @intCast(buffer.len)); + const value = std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]) catch return; + state.allocator.free(state.values[index]); + state.values[index] = value; + }, + } +} + +fn readPolicy(state: *DialogState) void { + if (state.kind != .worktree_policy) return; + readValue(state, 0); + readValue(state, 1); + const action: WorktreeStatus.ResolveAction = if (c.SendMessageW(state.checks[0], c.BM_GETCHECK, 0, 0) == c.BST_CHECKED) + .remove + else if (c.SendMessageW(state.checks[1], c.BM_GETCHECK, 0, 0) == c.BST_CHECKED) + .ask + else + .keep; + state.policy.applyResolveAction(action); + state.policy.notice_size_gb = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[0], " \t\r\n"), 10) catch state.policy.notice_size_gb; + state.policy.notice_count = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[1], " \t\r\n"), 10) catch state.policy.notice_count; +} + +fn validationReason(state: *DialogState) ?[]const u8 { + switch (state.kind) { + .node => { + const goal_based = std.mem.eql(u8, state.values[1], "goalBased"); + const poll = parseRequiredFloat(if (goal_based) state.values[8] else state.initial_values[8]) catch + return "Enter a valid number of seconds between goal checks."; + const stall = parseOptionalFloat(if (goal_based) state.values[9] else state.initial_values[9]) catch + return "Enter a valid stall timeout, or leave it blank."; + const backend: ?[]const u8 = if (std.mem.trim(u8, state.values[12], " \t\r\n").len == 0) null else state.values[12]; + Forms.validateNode(.{ + .title = state.values[0], + .loop_type = state.values[1], + .check_description = state.values[2], + .trigger_prompt = state.values[3], + .first_instruction = state.values[4], + .pauses_before_writes_only = std.mem.eql(u8, state.values[5], "true"), + .goal_summary = state.values[6], + .goal_predicate = state.values[7], + .poll_interval_seconds = poll, + .stall_after_seconds = stall, + .metric_command = state.values[10], + .metric_direction = state.values[11], + .backend = backend, + .model_tier = state.values[13], + .worktree_repository = state.values[14], + .worktree_id = state.values[15], + .worktree_path = state.values[16], + .worktree_branch = state.values[17], + .subgraph_json = state.values[18], + .created_by = state.values[19], + }) catch |err| return formErrorReason(err); + }, + .edge => { + const cycle_max = parseOptionalInt(state.values[7]) catch return "Maximum passes must be a whole number."; + const cycle_stop = parseOptionalInt(state.values[8]) catch return "Flat metric passes must be a whole number."; + Forms.validateEdge(.{ + .from = state.values[0], + .to = state.values[1], + .kind = state.values[2], + .condition = state.values[3], + .transform_kind = state.values[4], + .transform_value = state.values[5], + .cycle_until = state.values[6], + .cycle_max_iterations = cycle_max, + .cycle_stop_after_passes = cycle_stop, + .spawn_target_project_path = state.values[9], + }) catch |err| return formErrorReason(err); + }, + .update => { + if (parseOptionalFloat(state.values[2])) |value| { + if (value) |number| if (number <= 0) return "Poll interval must be greater than zero."; + } else |_| return "Poll interval must be a valid number."; + if (parseOptionalFloat(state.values[3])) |_| {} else |_| return "Stall timeout must be a valid number."; + if (!hasUpdateChanges(state)) return "Change at least one field, or choose Cancel."; + }, + .jump => { + _ = Forms.validateJumpQuery(state.values[0]) catch return "Enter a loop title or ID."; + }, + .worktree_policy => { + const size = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[0], " \t\r\n"), 10) catch + return "Enter a positive whole-number GB threshold."; + const count = std.fmt.parseInt(u32, std.mem.trim(u8, state.values[1], " \t\r\n"), 10) catch + return "Enter a positive whole-number worktree threshold."; + if (size == 0 or count == 0) return "Worktree notice thresholds must be greater than zero."; + }, + else => {}, + } + + return null; +} + +fn hasUpdateChanges(state: *const DialogState) bool { + for ([_]usize{ 1, 4, 6, 7 }) |index| { + if (!std.mem.eql( + u8, + std.mem.trim(u8, state.values[index], " \t\r\n"), + std.mem.trim(u8, state.initial_values[index], " \t\r\n"), + )) return true; + } + for ([_]usize{ 0, 5, 8 }) |index| { + const next = std.mem.trim(u8, state.values[index], " \t\r\n"); + if (next.len != 0 and !std.mem.eql( + u8, + next, + std.mem.trim(u8, state.initial_values[index], " \t\r\n"), + )) return true; + } + const poll = parseOptionalFloat(state.values[2]) catch return true; + const initial_poll = parseOptionalFloat(state.initial_values[2]) catch return true; + if (poll != initial_poll) return true; + const stall = parseOptionalFloat(state.values[3]) catch return true; + const initial_stall = parseOptionalFloat(state.initial_values[3]) catch return true; + return stall != initial_stall; +} + +fn formErrorReason(err: anyerror) []const u8 { + return switch (err) { + error.EmptyTitle => "Name this proactive loop to continue.", + error.MissingFirstInstruction => "Add a first instruction to continue.", + error.MissingTriggerPrompt => "Say what to do each time to continue.", + error.InvalidGoal => "Say what done looks like and use positive timing values.", + error.UnsupportedTransform => "Enter the template or script that should carry context.", + error.InvalidCycleGuard => "Cycle limits must be positive whole numbers.", + error.SameEndpoint => "Source and target must be different loops.", + error.MissingSource, error.MissingTarget => "This connection needs both endpoint identities.", + error.UnsupportedBackend => "Choose a supported agent.", + error.UnsupportedModelTier => "Choose a supported model tier.", + else => "Review the choices and required fields.", + }; +} + +fn freeValues(state: *DialogState) void { + for (&state.values) |value| if (value.len != 0) state.allocator.free(value); + for (&state.initial_values) |value| if (value.len != 0) state.allocator.free(value); + for (&state.display_labels) |value| if (value.len != 0) state.allocator.free(value); +} + +fn utf8ToWideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +test "modal submit and cancel transitions always terminate the loop" { + var state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; + applyModalCommand(&state, .submit); + try std.testing.expect(state.closed); + try std.testing.expect(state.result); + state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; + applyModalCommand(&state, .cancel); + try std.testing.expect(state.closed); + try std.testing.expect(!state.result); + state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; + applyModalCommand(&state, .close); + try std.testing.expect(state.closed); + state.result = true; + applyModalCommand(&state, .destroy); + try std.testing.expect(state.closed); + try std.testing.expect(state.result); +} + +test "jump modal result uses production query validation" { + var state = DialogState{ .allocator = undefined, .kind = .jump, .parent = null }; + state.values[0] = @constCast(" \t\r\n"); + try std.testing.expectError(error.EmptyJumpQuery, Forms.validateJumpQuery(state.values[0])); + state.values[0] = @constCast("Beta"); + try std.testing.expectEqualStrings("Beta", try Forms.validateJumpQuery(state.values[0])); +} + +test "numeric form values reject malformed input instead of substituting defaults" { + try std.testing.expectError(error.InvalidCharacter, parseRequiredFloat("not-a-number")); + try std.testing.expectError(error.InvalidCharacter, parseOptionalInt("3x")); + try std.testing.expectEqual(@as(?f64, null), try parseOptionalFloat(" \t")); + try std.testing.expectEqual(@as(?i64, 7), try parseOptionalInt("7")); +} + +test "guided choices map human labels to stable wire values" { + try std.testing.expectEqual(@as(usize, 2), choiceIndex(.loop_type, "goalBased")); + try std.testing.expectEqualStrings("goalBased", choiceValue(.loop_type, 2, "turnBased")); + try std.testing.expectEqualStrings("composite", choiceValue(.loop_type, 3, "composite")); + try std.testing.expectEqualStrings("copilotCLI", choiceValue(.backend, 2, "")); + try std.testing.expectEqualStrings("onFailure", choiceValue(.edge_condition, 2, "always")); + try std.testing.expectEqualStrings("script", choiceValue(.transform, 2, "none")); + const endpoints = [_]EdgeEndpoint{ + .{ .id = "node-a", .title = "Alpha" }, + .{ .id = "node-b", .title = "Beta" }, + }; + try std.testing.expectEqual(@as(usize, 1), endpointIndex(&endpoints, "node-b")); + try std.testing.expectEqual(@as(i32, 180), inputControlHeight(.combo)); + try std.testing.expectEqual(@as(i32, 24), inputControlHeight(.checkbox)); +} + +test "node draft builder preserves every hidden initial field" { + var values: [20][]u8 = .{@constCast("")} ** 20; + values[1] = @constCast("turnBased"); + values[4] = @constCast("Start here"); + values[5] = @constCast("false"); + values[8] = @constCast("60"); + values[11] = @constCast("maximize"); + const initial = Forms.NodeDraft{ + .title = "before", + .worktree_repository = "D:\\repo", + .worktree_id = "worktree-7", + .worktree_path = "D:\\repo-wt", + .worktree_branch = "feature/forms", + .subgraph_json = "", + .created_by = "11111111-1111-4111-8111-111111111111", + .claude_permissions = "plan", + .copilot_permissions = "readOnly", + .briefing_enabled = false, + .activity_enabled = true, + }; + var draft = try buildNodeDraft(std.testing.allocator, values, initial); + defer draft.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(initial.worktree_repository, draft.worktree_repository); + try std.testing.expectEqualStrings(initial.worktree_id, draft.worktree_id); + try std.testing.expectEqualStrings(initial.worktree_path, draft.worktree_path); + try std.testing.expectEqualStrings(initial.worktree_branch, draft.worktree_branch); + try std.testing.expectEqualStrings(initial.created_by, draft.created_by); + try std.testing.expectEqualStrings(initial.claude_permissions, draft.claude_permissions); + try std.testing.expectEqual(initial.briefing_enabled, draft.briefing_enabled); + try std.testing.expectEqual(initial.activity_enabled, draft.activity_enabled); + + var hidden_values = values; + hidden_values[8] = @constCast("not-a-number"); + hidden_values[9] = @constCast("also-invalid"); + var hidden_draft = try buildNodeDraft(std.testing.allocator, hidden_values, initial); + defer hidden_draft.deinit(std.testing.allocator); + try std.testing.expectEqual(initial.poll_interval_seconds, hidden_draft.poll_interval_seconds); + try std.testing.expectEqual(initial.stall_after_seconds, hidden_draft.stall_after_seconds); +} + +test "conditional graph fields and validation follow selected types" { + var node_state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; + node_state.field_count = 14; + for (0..14) |index| node_state.visible[index] = true; + node_state.values[1] = @constCast("goalBased"); + node_state.values[6] = @constCast(""); + node_state.values[8] = @constCast("60"); + node_state.values[11] = @constCast("maximize"); + updateConditionalVisibility(&node_state); + try std.testing.expect(node_state.visible[6]); + try std.testing.expect(!node_state.visible[4]); + try std.testing.expectEqualStrings("Say what done looks like and use positive timing values.", validationReason(&node_state).?); + + var edge_state = DialogState{ .allocator = undefined, .kind = .edge, .parent = null }; + edge_state.field_count = 10; + for (0..10) |index| edge_state.visible[index] = true; + edge_state.values[0] = @constCast("source"); + edge_state.values[1] = @constCast("target"); + edge_state.values[2] = @constCast("spawn"); + edge_state.values[3] = @constCast("always"); + edge_state.values[4] = @constCast("template"); + edge_state.values[5] = @constCast(""); + updateConditionalVisibility(&edge_state); + try std.testing.expect(edge_state.visible[5]); + try std.testing.expect(edge_state.visible[9]); + try std.testing.expect(edge_state.visible[3]); + try std.testing.expect(edge_state.visible[7]); + try std.testing.expectEqualStrings("Enter the template or script that should carry context.", validationReason(&edge_state).?); + + edge_state.values[4] = @constCast("none"); + edge_state.values[6] = @constCast("test -f done.flag"); + edge_state.values[7] = @constCast("4"); + edge_state.values[8] = @constCast("2"); + edge_state.values[9] = @constCast("D:\\other-project"); + var edge_draft = try buildEdgeDraft(std.testing.allocator, edge_state.values); + defer edge_draft.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("test -f done.flag", edge_draft.cycle_until); + try std.testing.expectEqual(@as(?i64, 4), edge_draft.cycle_max_iterations); + try std.testing.expectEqual(@as(?i64, 2), edge_draft.cycle_stop_after_passes); + try std.testing.expectEqualStrings("D:\\other-project", edge_draft.spawn_target_project_path); + +} + +test "graph form cancellation leaves draft values untouched" { + var state = DialogState{ .allocator = undefined, .kind = .edge, .parent = null }; + state.values[0] = @constCast("source-id"); + state.values[2] = @constCast("handoff"); + applyModalCommand(&state, .cancel); + try std.testing.expect(!state.result); + try std.testing.expect(state.closed); + try std.testing.expectEqualStrings("source-id", state.values[0]); + try std.testing.expectEqualStrings("handoff", state.values[2]); +} + +test "keyboard-sized guided form keeps every field reachable through bounded scrolling" { + const content: i32 = 54 + 10 * 64 + 12; + const viewport: i32 = 768 - 96; + const max_offset = boundedScrollOffset(content, viewport, 0, 100000); + try std.testing.expectEqual(max_offset, boundedScrollOffset(content, viewport, max_offset, 48)); + try std.testing.expectEqual(@as(i32, 0), boundedScrollOffset(content, viewport, 0, -48)); + const last_top: i32 = 54 + 9 * 64; + try std.testing.expect(last_top + 61 <= max_offset + viewport - 48); +} + +test "scrollbar thumb positions seek and clamp the dialog content" { + const content: i32 = 54 + 10 * 64 + 12; + const viewport: i32 = 768 - 96; + try std.testing.expectEqual(@as(i32, 0), std.math.clamp(@as(i32, 0), 0, content - (viewport - 48))); + const max_offset = boundedScrollOffset(content, viewport, 0, 100000); + try std.testing.expectEqual(@min(@as(i32, 200), max_offset), std.math.clamp(@as(i32, 200), 0, max_offset)); + try std.testing.expectEqual(max_offset, std.math.clamp(@as(i32, 100000), 0, max_offset)); +} diff --git a/graphcode-windows/src/Navigation.zig b/graphcode-windows/src/Navigation.zig new file mode 100644 index 00000000..8a2834b7 --- /dev/null +++ b/graphcode-windows/src/Navigation.zig @@ -0,0 +1,108 @@ +const std = @import("std"); + +pub const Identity = struct { + project_path: []const u8, + node_id: []const u8, + + pub fn eql(a: Identity, b: Identity) bool { + return std.mem.eql(u8, a.project_path, b.project_path) and + std.mem.eql(u8, a.node_id, b.node_id); + } +}; + +pub const Item = struct { + identity: Identity, + title: []const u8, + attention: bool = false, +}; + +pub const Search = struct { + pub fn matches(item: Item, query: []const u8) bool { + const needle = std.mem.trim(u8, query, " \t\r\n"); + return needle.len == 0 or containsIgnoreCase(item.title, needle) or + containsIgnoreCase(item.identity.node_id, needle) or + containsIgnoreCase(item.identity.project_path, needle); + } + + pub fn collect(items: []const Item, query: []const u8, output: []Item) usize { + var count: usize = 0; + for (items) |item| { + if (count == output.len) break; + if (matches(item, query)) { + output[count] = item; + count += 1; + } + } + return count; + } +}; + +pub const Cursor = struct { + current: ?Identity = null, + + pub fn next(self: *Cursor, items: []const Item) ?Item { + return self.step(items, 1, false); + } + + pub fn previous(self: *Cursor, items: []const Item) ?Item { + return self.step(items, -1, false); + } + + pub fn nextAttention(self: *Cursor, items: []const Item) ?Item { + return self.step(items, 1, true); + } + + fn step(self: *Cursor, items: []const Item, offset: isize, attention_only: bool) ?Item { + if (items.len == 0) return null; + var start: usize = if (self.current) |current| blk: { + for (items, 0..) |item, index| if (item.identity.eql(current)) break :blk index; + break :blk if (offset < 0) items.len else 0; + } else if (offset < 0) items.len else 0; + var checked: usize = 0; + while (checked < items.len) : (checked += 1) { + if (offset > 0) start = (start + 1) % items.len else start = (start + items.len - 1) % items.len; + if (attention_only and !items[start].attention) continue; + self.current = items[start].identity; + return items[start]; + } + return null; + } +}; + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len > haystack.len) return false; + var start: usize = 0; + while (start + needle.len <= haystack.len) : (start += 1) { + var equal = true; + for (needle, 0..) |byte, index| { + if (std.ascii.toLower(haystack[start + index]) != std.ascii.toLower(byte)) { + equal = false; + break; + } + } + if (equal) return true; + } + return false; +} + +test "palette searches title id and project across stable identities" { + const items = [_]Item{ + .{ .identity = .{ .project_path = "C:\\one", .node_id = "a" }, .title = "Build", }, + .{ .identity = .{ .project_path = "C:\\two", .node_id = "b" }, .title = "Review", }, + }; + var result: [2]Item = undefined; + try std.testing.expectEqual(@as(usize, 1), Search.collect(&items, "TWO", &result)); + try std.testing.expect(Identity.eql(result[0].identity, items[1].identity)); + try std.testing.expectEqual(@as(usize, 1), Search.collect(&items, "BUILD", &result)); +} + +test "navigation wraps and follows stable identity after reorder" { + const first = Item{ .identity = .{ .project_path = "p", .node_id = "a" }, .title = "A" }; + const second = Item{ .identity = .{ .project_path = "p", .node_id = "b" }, .title = "B" }; + const third = Item{ .identity = .{ .project_path = "p", .node_id = "c" }, .title = "C", .attention = true }; + var cursor = Cursor{ .current = first.identity }; + const reordered = [_]Item{ third, first, second }; + try std.testing.expectEqualStrings("B", cursor.next(&reordered).?.title); + try std.testing.expectEqualStrings("C", cursor.nextAttention(&reordered).?.title); + try std.testing.expectEqualStrings("B", cursor.previous(&reordered).?.title); +} diff --git a/graphcode-windows/src/QuickChats.zig b/graphcode-windows/src/QuickChats.zig new file mode 100644 index 00000000..408f8305 --- /dev/null +++ b/graphcode-windows/src/QuickChats.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const Wire = @import("Wire.zig"); + +pub const Availability = enum { + available, +}; + +pub const Operation = enum { + create, + open, + rename, + delete, +}; + +pub const Result = union(enum) { + accepted: Availability, +}; + +pub const Controller = struct { + pub fn availability(_: Controller) Availability { + return .available; + } + + pub fn request(_: Controller, _: Operation) Result { + return .{ .accepted = .available }; + } +}; + +pub fn protocolGapMessage(operation: Operation) []const u8 { + return switch (operation) { + .create => "Quick chats: create command unavailable", + .open => "Quick chats: open command unavailable", + .rename => "Quick chats: rename command unavailable", + .delete => "Quick chats: delete command unavailable", + }; +} + +test "quick chat operations are available through the daemon controller" { + const controller = Controller{}; + try std.testing.expectEqual(Availability.available, controller.availability()); + inline for (std.meta.tags(Operation)) |operation| { + const result = controller.request(operation); + try std.testing.expectEqual(Availability.available, result.accepted); + } +} + +test "authoritative wire command vocabulary includes every quick chat operation" { + try std.testing.expectEqualStrings("listQuickChats", Wire.commandName(.list_quick_chats)); + try std.testing.expectEqualStrings("createQuickChat", Wire.commandName(.create_quick_chat)); + try std.testing.expectEqualStrings("openQuickChat", Wire.commandName(.open_quick_chat)); + try std.testing.expectEqualStrings("renameQuickChat", Wire.commandName(.rename_quick_chat)); + try std.testing.expectEqualStrings("deleteQuickChat", Wire.commandName(.delete_quick_chat)); +} diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig new file mode 100644 index 00000000..948db6e5 --- /dev/null +++ b/graphcode-windows/src/Sidebar.zig @@ -0,0 +1,1122 @@ +const std = @import("std"); +const GraphModel = @import("GraphModel.zig"); +const WorktreeStatus = @import("WorktreeStatus.zig"); +const Tokens = @import("DesignTokens.zig"); +const c = @import("Win32.zig").c; + +pub const State = struct { + allocator: std.mem.Allocator, + local_collapsed: bool = false, + remote_collapsed: bool = false, + chats_collapsed: bool = false, + collapsed_projects: std.StringHashMapUnmanaged(void) = .empty, + expanded_nodes: std.StringHashMapUnmanaged(void) = .empty, + + pub fn init(allocator: std.mem.Allocator) State { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *State) void { + freeSet(self.allocator, &self.collapsed_projects); + freeSet(self.allocator, &self.expanded_nodes); + self.* = undefined; + } + + pub fn toggleProject(self: *State, path: []const u8) !void { + try toggleSet(self.allocator, &self.collapsed_projects, path); + } + + pub fn toggleNode(self: *State, id: []const u8) !void { + try toggleSet(self.allocator, &self.expanded_nodes, id); + } + + pub fn isProjectCollapsed(self: *const State, path: []const u8) bool { + return self.collapsed_projects.contains(path); + } + + pub fn isNodeExpanded(self: *const State, id: []const u8) bool { + return self.expanded_nodes.contains(id); + } + + pub fn clearExpandedNodes(self: *State) void { + freeSet(self.allocator, &self.expanded_nodes); + self.expanded_nodes = .empty; + } + + pub fn encode(self: *const State, allocator: std.mem.Allocator) ![]u8 { + var result: std.ArrayList(u8) = .empty; + errdefer result.deinit(allocator); + var iterator = self.expanded_nodes.keyIterator(); + while (iterator.next()) |id| try result.writer(allocator).print("expanded\t{s}\n", .{id.*}); + return result.toOwnedSlice(allocator); + } + + pub fn decode(self: *State, data: []const u8) !void { + var lines = std.mem.splitScalar(u8, data, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "expanded\t")) continue; + const id = line["expanded\t".len..]; + if (id.len != 0 and !self.expanded_nodes.contains(id)) + try self.expanded_nodes.put(self.allocator, try self.allocator.dupe(u8, id), {}); + } + } +}; + +pub const Store = struct { + allocator: std.mem.Allocator, + path: []u8, + + pub fn init(allocator: std.mem.Allocator) !Store { + const base = if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| + value + else |_| blk: { + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(profile); + break :blk try std.fs.path.join(allocator, &.{ profile, ".graphcode" }); + }; + defer allocator.free(base); + try std.fs.cwd().makePath(base); + return .{ + .allocator = allocator, + .path = try std.fs.path.join(allocator, &.{ base, "windows-sidebar-state.tsv" }), + }; + } + + pub fn deinit(self: *Store) void { + self.allocator.free(self.path); + self.* = undefined; + } + + pub fn load(self: *Store, state: *State) !void { + const data = std.fs.cwd().readFileAlloc(self.allocator, self.path, 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer self.allocator.free(data); + try state.decode(data); + } + + pub fn save(self: *Store, state: *const State) !void { + const data = try state.encode(self.allocator); + defer self.allocator.free(data); + const temp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp-{d}", .{ self.path, std.time.nanoTimestamp() }); + defer self.allocator.free(temp_path); + var file = try std.fs.cwd().createFile(temp_path, .{ .truncate = true }); + file.writeAll(data) catch |err| { + file.close(); + std.fs.cwd().deleteFile(temp_path) catch {}; + return err; + }; + file.close(); + std.os.windows.MoveFileEx( + temp_path, + self.path, + std.os.windows.MOVEFILE_REPLACE_EXISTING | std.os.windows.MOVEFILE_WRITE_THROUGH, + ) catch |err| { + std.fs.cwd().deleteFile(temp_path) catch {}; + return err; + }; + } +}; + +fn freeSet(allocator: std.mem.Allocator, set: *std.StringHashMapUnmanaged(void)) void { + var iterator = set.keyIterator(); + while (iterator.next()) |key| allocator.free(key.*); + set.deinit(allocator); +} + +fn toggleSet(allocator: std.mem.Allocator, set: *std.StringHashMapUnmanaged(void), key: []const u8) !void { + if (set.fetchRemove(key)) |removed| { + allocator.free(removed.key); + } else { + try set.put(allocator, try allocator.dupe(u8, key), {}); + } +} + +pub fn draw( + hdc: c.HDC, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + selected_worktree_path: []const u8, + scroll_offset: i32, + status: []const u8, + viewport_bottom: i32, + update_version: []const u8, + ingress_error: []const u8, + state: *const State, + hover_y: i32, + allocator: std.mem.Allocator, +) void { + const sidebar = rect(0, Tokens.header_height, Tokens.sidebar_width, 1200); + fill(hdc, sidebar, Tokens.workspace_rail); + drawText(hdc, allocator, "GRAPH", 18, Tokens.header_height + 20, 16, 0x00FFFFFF); + drawText(hdc, allocator, "Projects", 18, Tokens.header_height + 54, 14, 0x00B8B8B8); + var rows = appendRows(allocator, model, inspection, scroll_offset, state) catch return; + defer rows.deinit(allocator); + for (rows.items) |row| { + switch (row.kind) { + .local_heading => { + drawText(hdc, allocator, if (state.local_collapsed) "> LOCAL" else "v LOCAL", 18, row.top, 10, 0x007A7A7A); + }, + .remote_heading => { + drawText(hdc, allocator, if (state.remote_collapsed) "> REMOTE" else "v REMOTE", 18, row.top, 10, 0x007A7A7A); + }, + .project => { + const project = model.recent_projects.items[row.index]; + drawText(hdc, allocator, if (project.isRemote()) "R" else "L", 18, row.top + 1, 9, 0x007A7A7A); + drawText(hdc, allocator, project.name, 34, row.top, 13, 0x00E6E6E6); + }, + .overview => { + drawText(hdc, allocator, "G", 18, row.top, 11, 0x007AB8FF); + drawText(hdc, allocator, "Graph", 34, row.top, 13, 0x00E6E6E6); + }, + .open_project => if (row.project_path) |path| if (model.graphFor(path)) |summary| { + const selected = if (model.selected_project_path) |selected_path| + std.mem.eql(u8, selected_path, path) + else false; + drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 18, row.top, 9, 0x007A7A7A); + drawText(hdc, allocator, if (summary.project.isRemote()) "R" else "L", 31, row.top + 1, 9, 0x007A7A7A); + drawText(hdc, allocator, summary.project.name, 44, row.top, 13, + if (selected) 0x00FFFFFF else 0x00D0D0D0); + if (hover_y >= row.top and hover_y < row.top + 24) { + drawText(hdc, allocator, "+", 181, row.top, 13, 0x00B8B8B8); + if (row.has_children) drawText(hdc, allocator, if (state.isProjectCollapsed(path)) ">" else "v", 204, row.top, 9, 0x00B8B8B8); + } + }, + .loop => if (row.project_path) |path| if (model.graphFor(path)) |summary| { + if (row.index < summary.nodes.items.len) { + const node = summary.nodes.items[row.index]; + const indent = @as(i32, @intCast(row.depth * 12)); + if (row.depth != 0) drawText(hdc, allocator, ">", 28 + indent, row.top, 9, 0x006A6A6A); + fill(hdc, rect(30 + indent, row.top - 2, 33 + indent, row.top + 17), loopAccent(node.loop_type)); + drawText(hdc, allocator, node.title, 39 + indent, row.top, 11, 0x00E6E6E6); + drawText(hdc, allocator, compactState(node.state), 168, row.top, 9, stateColor(node.state)); + if (row.has_children and hover_y >= row.top and hover_y < row.top + 24) + drawText(hdc, allocator, if (state.isNodeExpanded(node.id)) "v" else ">", 204, row.top, 9, 0x00B8B8B8); + } + }, + .worktree => if (inspection) |value| { + const entry = value.entries.items[row.index]; + const selected = std.mem.eql(u8, entry.path, selected_worktree_path); + if (selected and WorktreeStatus.decision(entry) == .reclaimable) + fill(hdc, rect(12, row.top - 3, Tokens.sidebar_width - 12, row.top + 25), 0x003A3A44); + drawText(hdc, allocator, entry.path, 24, row.top, 11, 0x00E6E6E6); + drawText(hdc, allocator, reason(entry), 24, row.top + 14, 10, + if (WorktreeStatus.decision(entry) == .reclaimable) 0x0078D7A8 else 0x00FFCD7A); + }, + .quick_chat_overview => { + drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 18, row.top, 9, 0x007A7A7A); + drawText(hdc, allocator, "Quick Chats", 32, row.top, 13, 0x00E6E6E6); + if (hover_y >= row.top and hover_y < row.top + 24) { + drawText(hdc, allocator, "+", 181, row.top, 13, 0x00B8B8B8); + if (model.quick_chats.items.len != 0) + drawText(hdc, allocator, if (state.chats_collapsed) ">" else "v", 204, row.top, 9, 0x00B8B8B8); + } + }, + .quick_chat => if (row.index < model.quick_chats.items.len) + drawText(hdc, allocator, model.quick_chats.items[row.index].title, 24, row.top, 11, 0x00E6E6E6), + } + } + for (rows.items) |row| if (row.kind == .quick_chat_overview) { + drawText(hdc, allocator, "CHATS", 18, row.top - 32, 10, 0x007A7A7A); + break; + }; + + const section_y = sidebarSectionBottom(model, inspection, state) - scroll_offset; + if (model.attentionCount() != 0) { + drawText(hdc, allocator, "Needs you", 18, section_y + 10, 11, 0x00FFCD7A); + var attention_y = section_y + 30; + if (model.attention_entries.items.len != 0) { + for (model.attention_entries.items[0..@min(model.attention_entries.items.len, 4)]) |entry| { + drawText(hdc, allocator, entry.node.title, 24, attention_y, 11, 0x00E6E6E6); + drawText(hdc, allocator, attentionContext(model, entry), 24, attention_y + 15, 9, stateColor(entry.node.state)); + attention_y += 34; + } + } else { + for (model.attention.items[0..@min(model.attention.items.len, 4)]) |node| { + drawText(hdc, allocator, node.title, 24, attention_y, 11, 0x00E6E6E6); + drawText(hdc, allocator, compactState(node.state), 24, attention_y + 15, 9, stateColor(node.state)); + attention_y += 34; + } + } + + } + if (ingress_error.len != 0) { + const bounds = errorFooterRect(viewport_bottom); + fill(hdc, bounds, 0x00242448); + drawText(hdc, allocator, ingress_error, bounds.left + 10, bounds.top + 10, 10, 0x006060FF); + } + if (update_version.len != 0) { + const bounds = updateBannerRect(viewport_bottom, ingress_error.len != 0); + fill(hdc, bounds, 0x00352B1C); + drawText(hdc, allocator, "v", bounds.left + 10, bounds.top + 10, 14, 0x00FF840A); + drawText(hdc, allocator, "Update available", bounds.left + 30, bounds.top + 7, 12, 0x00F0F0F0); + const detail = std.fmt.allocPrint(allocator, "{s} · click to install", .{update_version}) catch null; + defer if (detail) |value| allocator.free(value); + drawText(hdc, allocator, detail orelse update_version, bounds.left + 30, bounds.top + 24, 10, 0x00909090); + } + const status_offset: i32 = 58 + + (if (ingress_error.len != 0) @as(i32, 50) else 0) + + (if (update_version.len != 0) @as(i32, 58) else 0); + drawText(hdc, allocator, status, 18, viewport_bottom - status_offset, 11, 0x00909090); +} + +pub fn errorFooterRect(viewport_bottom: i32) c.RECT { + return rect(8, viewport_bottom - 84, Tokens.sidebar_width - 8, viewport_bottom - 42); +} + +pub fn updateBannerRect(viewport_bottom: i32, has_error: bool) c.RECT { + const error_offset: i32 = if (has_error) 50 else 0; + return rect(8, viewport_bottom - 92 - error_offset, Tokens.sidebar_width - 8, viewport_bottom - 42 - error_offset); +} + +pub fn updateBannerAt(x: i32, y: i32, viewport_bottom: i32, available: bool, has_error: bool) bool { + if (!available) return false; + const bounds = updateBannerRect(viewport_bottom, has_error); + return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; +} + +fn loopAccent(loop_type: []const u8) u32 { + if (std.mem.eql(u8, loop_type, "goalBased")) return 0x0048C78E; + if (std.mem.eql(u8, loop_type, "timeBased")) return 0x00D6A649; + if (std.mem.eql(u8, loop_type, "composite")) return 0x00C77DFF; + return 0x007AB8FF; +} + +fn compactState(state: []const u8) []const u8 { + if (std.mem.eql(u8, state, "succeeded")) return "done"; + if (std.mem.eql(u8, state, "awaitingInput")) return "needs"; + return state; +} + +fn stateColor(state: []const u8) u32 { + if (std.mem.eql(u8, state, "failed") or std.mem.eql(u8, state, "stalled")) return 0x005F5FFF; + if (std.mem.eql(u8, state, "succeeded")) return 0x006BD58D; + if (std.mem.eql(u8, state, "blocked")) return 0x0049B8FF; + return 0x008E8E93; +} + +fn attentionContext(model: *const GraphModel.Model, entry: GraphModel.AttentionEntry) []const u8 { + for (model.graphs.items) |graph| { + if (std.mem.eql(u8, graph.project.path, entry.project_path)) return graph.project.name; + } + return compactState(entry.node.state); +} + +pub fn loopRowTop(project_count: usize, index: usize) i32 { + const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = 0, .worktree_count = 0 }; + return layout.loopTop(index); +} + +pub fn loopRowTopForModel(model: *const GraphModel.Model, index: usize) i32 { + const layout = layoutFor(model, null); + const graph = model.currentGraph() orelse return layout.loopTop(index); + const position = hierarchyPosition(std.heap.page_allocator, graph.nodes.items, graph.edges.items, index) catch index; + return layout.loopTop(position); +} + +pub fn worktreeRowTop(project_count: usize, loop_count: usize, index: usize) i32 { + const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = loop_count, .worktree_count = 0 }; + return layout.worktreeTop(index); +} + +pub fn worktreeRowTopForModel(model: *const GraphModel.Model, loop_count: usize, index: usize) i32 { + const layout = Layout{ + .base = Tokens.header_height + 78, + .project_count = model.recent_projects.items.len, + .project_heading_count = projectHeadingCount(model), + .loop_count = loop_count, + .worktree_count = 0, + }; + return layout.worktreeTop(index); +} + +pub const RowKind = enum { local_heading, remote_heading, project, open_project, overview, loop, worktree, quick_chat_overview, quick_chat }; +pub const Row = struct { + kind: RowKind, + index: usize, + top: i32, + project_path: ?[]const u8 = null, + depth: usize = 0, + has_children: bool = false, +}; +const HierarchyItem = struct { index: usize, depth: usize, has_children: bool }; +pub const Layout = struct { + base: i32, + project_count: usize, + project_heading_count: usize = 0, + loop_count: usize, + worktree_count: usize, + quick_chat_count: usize = 0, + graph_present: bool = true, + inspection_present: bool = false, + graph_section_height: i32 = 0, + + pub fn projectTop(self: Layout, index: usize) i32 { + return self.base + @as(i32, @intCast(index * 24)); + } + pub fn overviewTop(self: Layout) i32 { + return self.base + self.projectSectionHeight() + 24; + } + pub fn loopTop(self: Layout, index: usize) i32 { + return self.base + self.projectSectionHeight() + 86 + + @as(i32, @intCast(index * 24)); + } + pub fn worktreeTop(self: Layout, index: usize) i32 { + return self.base + self.projectSectionHeight() + 140 + + @as(i32, @intCast(self.loop_count * 24)) + + @as(i32, @intCast(index * 34)); + } + pub fn quickChatHeadingTop(self: Layout) i32 { + return self.quickChatRowTop(0) - 32; + } + pub fn quickChatRowTop(self: Layout, index: usize) i32 { + const project_bottom = self.base + self.projectSectionHeight(); + const worktree_height: i32 = if (self.inspection_present) + 24 + @as(i32, @intCast(self.worktree_count * 34)) + else + 0; + const section_bottom = project_bottom + self.graph_section_height + worktree_height; + return section_bottom + 42 + @as(i32, @intCast(index * 24)); + } + fn projectSectionHeight(self: Layout) i32 { + return @as(i32, @intCast((self.project_count + self.project_heading_count) * 24)); + } +}; + +pub fn appendRows( + allocator: std.mem.Allocator, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + scroll_offset: i32, + state: ?*const State, +) !std.ArrayList(Row) { + var rows: std.ArrayList(Row) = .empty; + var top: i32 = Tokens.header_height + 78 - scroll_offset; + const local_collapsed = if (state) |value| value.local_collapsed else false; + const remote_collapsed = if (state) |value| value.remote_collapsed else false; + const chats_collapsed = if (state) |value| value.chats_collapsed else false; + if (hasLocalProjects(model)) { + try rows.append(allocator, .{ .kind = .local_heading, .index = 0, .top = top }); + top += 24; + } + if (!local_collapsed) { + for (model.recent_projects.items, 0..) |project, index| { + if (project.isRemote()) continue; + try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); + top += 24; + } + } + if (hasRemoteProjects(model)) { + try rows.append(allocator, .{ .kind = .remote_heading, .index = 0, .top = top }); + top += 24; + } + if (!remote_collapsed) { + for (model.recent_projects.items, 0..) |project, index| { + if (!project.isRemote()) continue; + try rows.append(allocator, .{ .kind = .project, .index = index, .top = top, .project_path = project.path }); + top += 24; + } + } + try rows.append(allocator, .{ .kind = .overview, .index = 0, .top = top + 24 }); + top += 62; + if (model.graphs.items.len != 0 or model.graph != null) { + if (model.graphs.items.len != 0) { + for (model.graphs.items, 0..) |summary, graph_index| { + try rows.append(allocator, .{ .kind = .open_project, .index = graph_index, .top = top, .project_path = summary.project.path, .has_children = summary.nodes.items.len != 0 }); + top += 24; + if (state == null or !state.?.isProjectCollapsed(summary.project.path)) { + var hierarchy = try hierarchyItems(allocator, summary.nodes.items, summary.edges.items, state); + defer hierarchy.deinit(allocator); + for (hierarchy.items) |item| { + try rows.append(allocator, .{ .kind = .loop, .index = item.index, .top = top, .project_path = summary.project.path, .depth = item.depth, .has_children = item.has_children }); + top += 24; + } + } + top += 38; + } + } else if (model.graph) |graph| { + try rows.append(allocator, .{ .kind = .open_project, .index = 0, .top = top, .project_path = graph.project.path, .has_children = graph.nodes.items.len != 0 }); + top += 24; + if (state == null or !state.?.isProjectCollapsed(graph.project.path)) { + var hierarchy = try hierarchyItems(allocator, graph.nodes.items, graph.edges.items, state); + defer hierarchy.deinit(allocator); + for (hierarchy.items) |item| { + try rows.append(allocator, .{ .kind = .loop, .index = item.index, .top = top, .project_path = graph.project.path, .depth = item.depth, .has_children = item.has_children }); + top += 24; + } + } + top += 38; + } + } + if (inspection) |value| { + top += 24; + for (value.entries.items, 0..) |_, index| { + try rows.append(allocator, .{ .kind = .worktree, .index = index, .top = top }); + top += 34; + } + } + top += 42; + try rows.append(allocator, .{ .kind = .quick_chat_overview, .index = 0, .top = top }); + top += 24; + if (!chats_collapsed) { + for (model.quick_chats.items, 0..) |_, index| { + try rows.append(allocator, .{ .kind = .quick_chat, .index = index, .top = top }); + top += 24; + } + } + return rows; +} + +pub fn layoutFor(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection) Layout { + var loop_count: usize = 0; + var graph_section_height: i32 = 62; + if (model.graphs.items.len != 0) { + for (model.graphs.items) |summary| { + loop_count += summary.nodes.items.len; + graph_section_height += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); + } + } else if (model.graph) |graph| { + loop_count = graph.nodes.items.len; + graph_section_height = 124 + @as(i32, @intCast(graph.nodes.items.len * 24)); + } + + return .{ + .base = Tokens.header_height + 78, + .project_count = model.recent_projects.items.len, + .project_heading_count = projectHeadingCount(model), + .loop_count = loop_count, + .worktree_count = if (inspection) |value| value.entries.items.len else 0, + .quick_chat_count = model.quick_chats.items.len, + .graph_present = true, + .inspection_present = inspection != null, + .graph_section_height = graph_section_height, + }; +} + +pub fn sharedGraphTop(model: *const GraphModel.Model, graph_index: usize) i32 { + var top = Tokens.header_height + 78 + + @as(i32, @intCast((model.recent_projects.items.len + projectHeadingCount(model)) * 24)) + 36; + for (model.graphs.items[0..@min(graph_index, model.graphs.items.len)]) |summary| { + top += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); + } + return top; +} + +pub fn sharedLoopTop(model: *const GraphModel.Model, graph_index: usize, node_index: usize) i32 { + if (graph_index >= model.graphs.items.len) return sharedGraphTop(model, graph_index) + 48; + const graph = model.graphs.items[graph_index]; + const position = hierarchyPosition(std.heap.page_allocator, graph.nodes.items, graph.edges.items, node_index) catch node_index; + return sharedGraphTop(model, graph_index) + 48 + @as(i32, @intCast(position * 24)); +} + +pub fn sharedWorktreeTop(model: *const GraphModel.Model, index: usize) i32 { + var top = Tokens.header_height + 78 + + @as(i32, @intCast((model.recent_projects.items.len + projectHeadingCount(model)) * 24)) + 36; + for (model.graphs.items) |summary| { + top += 62 + @as(i32, @intCast(summary.nodes.items.len * 24)); + } + return top + 24 + @as(i32, @intCast(index * 34)); +} + +pub fn rowAt( + x: i32, + y: i32, + model: *const GraphModel.Model, + inspection: ?*const WorktreeStatus.Inspection, + scroll_offset: i32, + viewport_bottom: i32, + state: ?*const State, +) ?Row { + if (x < 0 or x >= Tokens.sidebar_width or y < Tokens.header_height or y >= viewport_bottom) return null; + var rows = appendRows(std.heap.page_allocator, model, inspection, scroll_offset, state) catch return null; + defer rows.deinit(std.heap.page_allocator); + for (rows.items) |row| { + const height: i32 = switch (row.kind) { + .local_heading, .remote_heading, .project, .open_project, .overview, .loop, .quick_chat_overview, .quick_chat => 24, + .worktree => 34, + }; + if (y >= row.top and y < row.top + height) return row; + } + return null; +} + +fn hasLocalProjects(model: *const GraphModel.Model) bool { + for (model.recent_projects.items) |project| if (!project.isRemote()) return true; + return false; +} + +fn hasRemoteProjects(model: *const GraphModel.Model) bool { + for (model.recent_projects.items) |project| if (project.isRemote()) return true; + return false; +} + +fn projectHeadingCount(model: *const GraphModel.Model) usize { + return @as(usize, @intFromBool(hasLocalProjects(model))) + + @as(usize, @intFromBool(hasRemoteProjects(model))); +} + +fn hierarchyItems( + allocator: std.mem.Allocator, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + state: ?*const State, +) !std.ArrayList(HierarchyItem) { + var result: std.ArrayList(HierarchyItem) = .empty; + errdefer result.deinit(allocator); + const visited = try allocator.alloc(bool, nodes.len); + defer allocator.free(visited); + @memset(visited, false); + for (nodes, 0..) |node, index| { + var incoming = false; + for (edges) |edge| { + if (std.mem.eql(u8, edge.kind, "handoff") and std.mem.eql(u8, edge.to, node.id)) { + incoming = true; + break; + } + } + if (!incoming) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + } + for (nodes, 0..) |_, index| { + if (!visited[index]) try appendHierarchy(allocator, &result, visited, nodes, edges, index, 0, state); + } + return result; +} + +fn appendHierarchy( + allocator: std.mem.Allocator, + result: *std.ArrayList(HierarchyItem), + visited: []bool, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + index: usize, + depth: usize, + state: ?*const State, +) !void { + if (index >= nodes.len or visited[index]) return; + visited[index] = true; + var has_children = false; + for (edges) |edge| { + if (std.mem.eql(u8, edge.kind, "handoff") and + std.mem.eql(u8, edge.from, nodes[index].id) and + GraphModel.findNodeIndexByID(nodes, edge.to) != null) + { + has_children = true; + break; + } + } + try result.append(allocator, .{ .index = index, .depth = depth, .has_children = has_children }); + if (depth != 0 or has_children) { + if (state) |value| if (!value.isNodeExpanded(nodes[index].id)) { + markDescendantsVisited(visited, nodes, edges, index); + return; + }; + } + for (edges) |edge| { + if (!std.mem.eql(u8, edge.kind, "handoff") or + !std.mem.eql(u8, edge.from, nodes[index].id)) continue; + const child = GraphModel.findNodeIndexByID(nodes, edge.to) orelse continue; + try appendHierarchy(allocator, result, visited, nodes, edges, child, depth + 1, state); + } +} + +fn markDescendantsVisited( + visited: []bool, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + index: usize, +) void { + if (index >= nodes.len) return; + for (edges) |edge| { + if (!std.mem.eql(u8, edge.kind, "handoff") or + !std.mem.eql(u8, edge.from, nodes[index].id)) continue; + const child = GraphModel.findNodeIndexByID(nodes, edge.to) orelse continue; + if (visited[child]) continue; + visited[child] = true; + markDescendantsVisited(visited, nodes, edges, child); + } +} + +fn hierarchyPosition( + allocator: std.mem.Allocator, + nodes: []const GraphModel.Node, + edges: []const GraphModel.Edge, + node_index: usize, +) !usize { + var hierarchy = try hierarchyItems(allocator, nodes, edges, null); + defer hierarchy.deinit(allocator); + for (hierarchy.items, 0..) |item, position| if (item.index == node_index) return position; + return node_index; +} + +pub fn worktreeSectionBottom(project_count: usize, worktree_count: usize) i32 { + return worktreeRowTop(project_count, 0, worktree_count) + 10; +} + +pub fn contentBottom(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, state: ?*const State) i32 { + const section = sidebarSectionBottom(model, inspection, state); + return if (model.attentionCount() == 0) section else section + 30 + + @as(i32, @intCast(@min(model.attentionCount(), 4))) * 34; +} + +pub fn sidebarSectionBottom(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, state: ?*const State) i32 { + var rows = appendRows(std.heap.page_allocator, model, inspection, 0, state) catch return Tokens.header_height; + defer rows.deinit(std.heap.page_allocator); + if (rows.items.len == 0) return Tokens.header_height; + const last = rows.items[rows.items.len - 1]; + return last.top + (if (last.kind == .worktree) @as(i32, 34) else 24); +} + +pub fn maxScroll(model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, viewport_bottom: i32, state: ?*const State) i32 { + return @max(contentBottom(model, inspection, state) - viewport_bottom, 0); +} + +pub fn clampScroll(value: i32, maximum: i32) i32 { + return @min(@max(value, 0), @max(maximum, 0)); +} + +pub fn hitTestWorktree(x: i32, y: i32, project_count: usize, count: usize, scroll_offset: i32, viewport_bottom: i32) ?usize { + if (x < 12 or x >= Tokens.sidebar_width) return null; + if (y < Tokens.header_height or y >= viewport_bottom) return null; + const layout = Layout{ .base = Tokens.header_height + 78, .project_count = project_count, .loop_count = 0, .worktree_count = 0 }; + const top = layout.worktreeTop(0) - scroll_offset; + if (y < top) return null; + const index: usize = @intCast(@divTrunc(y - top, 34)); + if (index >= count) return null; + return index; +} + +pub fn hitTestProject(x: i32, y: i32, model: *const GraphModel.Model, scroll_offset: i32, viewport_bottom: i32) ?usize { + if (x < 0 or x >= Tokens.sidebar_width or y < Tokens.header_height or y >= viewport_bottom) return null; + const top = Tokens.header_height + 78 - scroll_offset; + if (y < top) return null; + const index: usize = @intCast(@divTrunc(y - top, 24)); + if (index >= model.recent_projects.items.len) return null; + return index; +} + +pub fn hitTestOverviewLoop(x: i32, y: i32, model: *const GraphModel.Model, scroll_offset: i32, viewport_bottom: i32) ?usize { + if (x < 0 or x >= Tokens.sidebar_width or y < Tokens.header_height or y >= viewport_bottom) return null; + const top = Tokens.header_height + 78 + + @as(i32, @intCast(model.recent_projects.items.len * 24)) - scroll_offset; + if (model.graph == null and model.graphs.items.len == 0 or y < top) return null; + const index: usize = @intCast(@divTrunc(y - top, 24)); + if (index >= layoutFor(model, null).loop_count) return null; + return index; +} + +fn reason(entry: WorktreeStatus.Entry) []const u8 { + if (entry.primary) return "primary checkout"; + if (entry.locked) return "locked"; + if (entry.prunable) return "prunable/stale"; + if (entry.bound_running) return "bound to active loop"; + if (entry.dirty or entry.untracked or entry.conflicted) return "local changes"; + if (!entry.pushed) return "unpushed commits"; + if (!entry.landed) return "not landed on default"; + return if (WorktreeStatus.decision(entry) == .reclaimable) "safe to reclaim" else "unsafe to reclaim"; +} + +test "worktree row hit testing selects only visible rows" { + const top = worktreeRowTop(2, 0, 0); + try std.testing.expectEqual(@as(?usize, 0), hitTestWorktree(24, top + 4, 2, 2, 0, 700)); + try std.testing.expectEqual(@as(?usize, 1), hitTestWorktree(24, top + 34 + 4, 2, 2, 0, 700)); + try std.testing.expectEqual(@as(?usize, null), hitTestWorktree(Tokens.sidebar_width + 1, top, 2, 2, 0, 700)); + try std.testing.expectEqual(@as(?usize, null), hitTestWorktree(24, top + 68, 2, 2, 0, 700)); + try std.testing.expectEqual(@as(i32, worktreeRowTop(3, 0, 0) - worktreeRowTop(1, 0, 0)), 48); +} + +test "shared sidebar layout routes every loop row after project rows and scroll" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .name = try std.testing.allocator.dupe(u8, "Project"), + .path = try std.testing.allocator.dupe(u8, "C:\\project"), + }); + var graph = GraphModel.Graph{ + .project = .{ + .path = try std.testing.allocator.dupe(u8, "C:\\project"), + .name = try std.testing.allocator.dupe(u8, "Project"), + }, + .nodes = std.array_list.Managed(GraphModel.Node).init(std.testing.allocator), + .edges = std.array_list.Managed(GraphModel.Edge).init(std.testing.allocator), + }; + try graph.nodes.append(.{ + .id = try std.testing.allocator.dupe(u8, "node-7"), + .title = try std.testing.allocator.dupe(u8, "Seven"), + .loop_type = try std.testing.allocator.dupe(u8, ""), + .state = try std.testing.allocator.dupe(u8, ""), + .activity = try std.testing.allocator.dupe(u8, ""), + .presence = try std.testing.allocator.dupe(u8, ""), + }); + try graph.nodes.append(.{ + .id = try std.testing.allocator.dupe(u8, "node-42"), + .title = try std.testing.allocator.dupe(u8, "Forty two"), + .loop_type = try std.testing.allocator.dupe(u8, ""), + .state = try std.testing.allocator.dupe(u8, ""), + .activity = try std.testing.allocator.dupe(u8, ""), + .presence = try std.testing.allocator.dupe(u8, ""), + }); + model.graph = graph; + const layout = layoutFor(&model, null); + for (0..2) |index| { + const row = rowAt(24, layout.loopTop(index) - 11, &model, null, 11, 700, null) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.loop, row.kind); + try std.testing.expectEqual(index, row.index); + } + +} + +test "multi-project rows share render and hit-test offsets with project identity" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const local = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project.json", 64 * 1024); + defer allocator.free(local); + const remote = try std.fs.cwd().readFileAlloc(allocator, "fixtures/daemon-v2-multi-project-remote.json", 64 * 1024); + defer allocator.free(remote); + _ = try model.updateFromFrame(local); + _ = try model.updateFromFrame(remote); + const local_top = sharedLoopTop(&model, 0, 0); + const remote_top = sharedLoopTop(&model, 1, 0); + const local_row = rowAt(24, local_top + 4, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; + const remote_row = rowAt(24, remote_top + 4, &model, null, 0, 700, null) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("C:\\work\\local", local_row.project_path.?); + try std.testing.expectEqualStrings("ssh://build/remote", remote_row.project_path.?); + try std.testing.expectEqual( + layoutFor(&model, null).quickChatRowTop(model.quick_chats.items.len + 1), + sidebarSectionBottom(&model, null, null), + ); +} + +test "scroll-adjusted generated rows hit titles loops and worktrees" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a1","title":"Loop A","state":"running"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + try model.recent_projects.append(.{ + .path = try allocator.dupe(u8, "recent"), + .name = try allocator.dupe(u8, "Recent"), + }); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator), + .default_branch = try allocator.dupe(u8, "main"), + .project_path = try allocator.dupe(u8, "A"), + }; + defer WorktreeStatus.deinitInspection(allocator, &inspection); + try inspection.entries.append(.{ + .path = try allocator.dupe(u8, "wt"), + .branch = try allocator.dupe(u8, "main"), + }); + const scroll: i32 = 37; + var rows = try appendRows(allocator, &model, &inspection, scroll, null); + defer rows.deinit(allocator); + for (rows.items) |row| { + const hit = rowAt(24, row.top + 4, &model, &inspection, scroll, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(row.kind, hit.kind); + if (row.kind == .project or row.kind == .open_project or row.kind == .loop) + try std.testing.expectEqualStrings(row.project_path orelse "recent", hit.project_path orelse "recent"); + } +} + +test "sidebar scroll clamps overflow, shrink, and resize" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + for (0..3) |index| { + try model.recent_projects.append(.{ + .path = try std.fmt.allocPrint(std.testing.allocator, "project-{d}", .{index}), + .name = try std.fmt.allocPrint(std.testing.allocator, "Project {d}", .{index}), + }); + } + for (0..4) |_| try model.attention.append(.{ + .id = try std.testing.allocator.dupe(u8, "attention"), + .title = try std.testing.allocator.dupe(u8, "Needs You"), + .loop_type = try std.testing.allocator.dupe(u8, "goal"), + .state = try std.testing.allocator.dupe(u8, "failed"), + .activity = try std.testing.allocator.dupe(u8, "failed"), + .presence = try std.testing.allocator.dupe(u8, "idle"), + .worktree_path = try std.testing.allocator.dupe(u8, ""), + .worktree_branch = try std.testing.allocator.dupe(u8, ""), + }); + model.graph = .{ + .project = .{ + .path = try std.testing.allocator.dupe(u8, "project-0"), + .name = try std.testing.allocator.dupe(u8, "Project 0"), + }, + .nodes = std.array_list.Managed(GraphModel.Node).init(std.testing.allocator), + .edges = std.array_list.Managed(GraphModel.Edge).init(std.testing.allocator), + }; + for (0..3) |_| try model.activity.append(.{ + .title = try std.testing.allocator.dupe(u8, "Activity"), + .state = try std.testing.allocator.dupe(u8, "succeeded"), + }); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(std.testing.allocator), + .default_branch = @constCast("main"), + .project_path = @constCast("project-0"), + }; + for (0..5) |_| try inspection.entries.append(.{ + .path = @constCast("worktree"), + .branch = @constCast("branch"), + }); + const short_max = maxScroll(&model, &inspection, 400, null); + const expected_short = sidebarSectionBottom(&model, &inspection, null) + 30 + 4 * 34 - 400; + try std.testing.expectEqual(expected_short, short_max); + const activity_only_max = maxScroll(&model, &inspection, 400, null); + try std.testing.expectEqual(short_max, activity_only_max); + var scroll: i32 = 0; + for (0..10) |_| scroll = clampScroll(scroll + 40, short_max); + try std.testing.expectEqual(short_max, scroll); + while (model.activity.items.len > 1) { + const event = model.activity.pop() orelse break; + std.testing.allocator.free(event.title); + std.testing.allocator.free(event.state); + } + try std.testing.expectEqual(short_max, maxScroll(&model, &inspection, 400, null)); + while (model.recent_projects.items.len > 1) { + const project = model.recent_projects.pop() orelse break; + std.testing.allocator.free(project.path); + std.testing.allocator.free(project.name); + } + while (model.attention.items.len > 1) { + const node = model.attention.pop() orelse break; + std.testing.allocator.free(node.id); + std.testing.allocator.free(node.title); + std.testing.allocator.free(node.loop_type); + std.testing.allocator.free(node.state); + std.testing.allocator.free(node.activity); + std.testing.allocator.free(node.presence); + std.testing.allocator.free(node.worktree_path); + std.testing.allocator.free(node.worktree_branch); + } + inspection.entries.shrinkRetainingCapacity(2); + const reduced_max = maxScroll(&model, &inspection, 500, null); + const expected_reduced = @max(sidebarSectionBottom(&model, &inspection, null) + 30 + 1 * 34 - 500, 0); + try std.testing.expectEqual(expected_reduced, reduced_max); + scroll = clampScroll(scroll, reduced_max); + try std.testing.expectEqual(reduced_max, scroll); + try std.testing.expectEqual(@as(i32, 0), clampScroll(-50, reduced_max)); + inspection.entries.deinit(); +} + +test "sidebar without graph counts only static rendered content" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "project"), + .name = try std.testing.allocator.dupe(u8, "Project"), + }); + const no_graph_max = maxScroll(&model, null, 100, null); + try std.testing.expectEqual(@as(i32, Tokens.header_height + 78 + 24 + 24 + 62 + 42 + 24 - 100), no_graph_max); + try std.testing.expectEqual(@as(i32, 180), clampScroll(180, no_graph_max)); +} + +test "global Graph row remains pinned without an open project" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + try std.testing.expectEqual(RowKind.overview, rows.items[0].kind); + const graph_row = rowAt(24, rows.items[0].top + 4, &model, null, 0, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.overview, graph_row.kind); +} + +test "recent projects are grouped into local and remote sections" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "ssh://host/repo"), + .name = try std.testing.allocator.dupe(u8, "Remote"), + }); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\repo"), + .name = try std.testing.allocator.dupe(u8, "Local"), + }); + var rows = try appendRows(std.testing.allocator, &model, null, 0, null); + defer rows.deinit(std.testing.allocator); + try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); + try std.testing.expectEqual(RowKind.project, rows.items[1].kind); + try std.testing.expectEqual(@as(usize, 1), rows.items[1].index); + try std.testing.expectEqual(RowKind.remote_heading, rows.items[2].kind); + try std.testing.expectEqual(@as(usize, 0), rows.items[3].index); +} + +test "handoff edges derive stable nested loop order and depth" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("child"), .title = @constCast("Child"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("root"), .title = @constCast("Root"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + }; + const edges = [_]GraphModel.Edge{.{ + .from = @constCast("root"), + .to = @constCast("child"), + .kind = @constCast("handoff"), + }}; + var hierarchy = try hierarchyItems(std.testing.allocator, &nodes, &edges, null); + defer hierarchy.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), hierarchy.items[0].index); + try std.testing.expectEqual(@as(usize, 0), hierarchy.items[0].depth); + try std.testing.expectEqual(@as(usize, 0), hierarchy.items[1].index); + try std.testing.expectEqual(@as(usize, 1), hierarchy.items[1].depth); +} + +test "message and spawn edges do not define sidebar hierarchy" { + const nodes = [_]GraphModel.Node{ + .{ .id = @constCast("first"), .title = @constCast("First"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + .{ .id = @constCast("second"), .title = @constCast("Second"), .loop_type = @constCast("turnBased"), .state = @constCast("idle"), .activity = @constCast(""), .presence = @constCast("idle") }, + }; + const edges = [_]GraphModel.Edge{ + .{ .from = @constCast("first"), .to = @constCast("second"), .kind = @constCast("message") }, + .{ .from = @constCast("second"), .to = @constCast("first"), .kind = @constCast("spawn") }, + }; + var hierarchy = try hierarchyItems(std.testing.allocator, &nodes, &edges, null); + defer hierarchy.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 2), hierarchy.items.len); + try std.testing.expectEqual(@as(usize, 0), hierarchy.items[0].depth); + try std.testing.expect(!hierarchy.items[0].has_children); + try std.testing.expectEqual(@as(usize, 0), hierarchy.items[1].depth); + try std.testing.expect(!hierarchy.items[1].has_children); +} + +test "sidebar groups and nested disclosures collapse independently and persist expansion" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "C:\\local"), + .name = try std.testing.allocator.dupe(u8, "Local"), + }); + try model.recent_projects.append(.{ + .path = try std.testing.allocator.dupe(u8, "ssh://host/remote"), + .name = try std.testing.allocator.dupe(u8, "Remote"), + }); + var state = State.init(std.testing.allocator); + defer state.deinit(); + state.local_collapsed = true; + var rows = try appendRows(std.testing.allocator, &model, null, 0, &state); + defer rows.deinit(std.testing.allocator); + try std.testing.expectEqual(RowKind.local_heading, rows.items[0].kind); + try std.testing.expectEqual(RowKind.remote_heading, rows.items[1].kind); + try std.testing.expectEqual(RowKind.project, rows.items[2].kind); + try std.testing.expectEqual(@as(usize, 1), rows.items[2].index); + + try state.toggleNode("root"); + const encoded = try state.encode(std.testing.allocator); + defer std.testing.allocator.free(encoded); + var restored = State.init(std.testing.allocator); + defer restored.deinit(); + try restored.decode(encoded); + try std.testing.expect(restored.isNodeExpanded("root")); + try restored.toggleNode("root"); + try std.testing.expect(!restored.isNodeExpanded("root")); +} + +test "daemon hierarchy starts with only roots visible and reveals children by stable id" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\fixture","name":"Fixture"},"nodes":[{"id":"root","title":"Root","state":"running"},{"id":"child","title":"Child","state":"idle"}],"edges":[{"id":"edge","from":"root","to":"child","kind":"handoff"}]}}} + ; + _ = try model.updateFromFrame(frame); + var state = State.init(std.testing.allocator); + defer state.deinit(); + var collapsed = try appendRows(std.testing.allocator, &model, null, 0, &state); + defer collapsed.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), countRows(collapsed.items, .loop)); + try state.toggleNode("root"); + var expanded = try appendRows(std.testing.allocator, &model, null, 0, &state); + defer expanded.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 2), countRows(expanded.items, .loop)); +} + +fn countRows(rows: []const Row, kind: RowKind) usize { + var count: usize = 0; + for (rows) |row| if (row.kind == kind) { + count += 1; + }; + return count; +} + +test "quick chats remain selectable without an open graph or inspection" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + try model.quick_chats.append(.{ + .id = try std.testing.allocator.dupe(u8, "chat-1"), + .title = try std.testing.allocator.dupe(u8, "Scratch"), + .backend = try std.testing.allocator.dupe(u8, "claudeCode"), + }); + const layout = layoutFor(&model, null); + try std.testing.expect(layout.quickChatHeadingTop() + 11 < layout.quickChatRowTop(0)); + const overview = rowAt(24, layout.quickChatRowTop(0) + 4, &model, null, 0, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.quick_chat_overview, overview.kind); + const row = rowAt(24, layout.quickChatRowTop(1) + 4, &model, null, 0, 700, null) orelse + return error.TestUnexpectedResult; + try std.testing.expectEqual(RowKind.quick_chat, row.kind); + try std.testing.expectEqual(@as(usize, 0), row.index); + try std.testing.expectEqual( + @as(i32, Tokens.header_height + 78 + 62 + 42), + layout.quickChatRowTop(0), + ); + try std.testing.expect(rowAt(24, layout.quickChatHeadingTop() + 4, &model, null, 0, 700, null) == null); + try std.testing.expectEqual(layout.quickChatRowTop(2), sidebarSectionBottom(&model, null, null)); +} + +test "quick chat heading and rows stay distinct across graph layouts" { + const layouts = [_]Layout{ + .{ .base = 100, .project_count = 2, .loop_count = 0, .worktree_count = 0, .graph_present = false }, + .{ .base = 100, .project_count = 2, .loop_count = 3, .worktree_count = 0 }, + .{ .base = 100, .project_count = 2, .loop_count = 3, .worktree_count = 4, .inspection_present = true }, + }; + for (layouts) |layout| { + try std.testing.expect(layout.quickChatHeadingTop() + 11 < layout.quickChatRowTop(0)); + try std.testing.expectEqual(@as(i32, 24), layout.quickChatRowTop(1) - layout.quickChatRowTop(0)); + } +} + +test "sidebar loop presentation preserves type and terminal states" { + try std.testing.expectEqual(@as(u32, 0x0048C78E), loopAccent("goalBased")); + try std.testing.expectEqualStrings("done", compactState("succeeded")); + try std.testing.expectEqualStrings("running", compactState("running")); + try std.testing.expectEqual(@as(u32, 0x005F5FFF), stateColor("failed")); +} + +test "update banner is a bounded footer action" { + const bounds = updateBannerRect(700, false); + try std.testing.expect(updateBannerAt(bounds.left, bounds.top, 700, true, false)); + try std.testing.expect(updateBannerAt(bounds.right - 1, bounds.bottom - 1, 700, true, false)); + try std.testing.expect(!updateBannerAt(bounds.right, bounds.bottom - 1, 700, true, false)); + try std.testing.expect(!updateBannerAt(bounds.left, bounds.top, 700, false, false)); + try std.testing.expect(updateBannerRect(700, true).bottom < errorFooterRect(700).top); +} + +fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { + return .{ .left = left, .top = top, .right = right, .bottom = bottom }; +} + +fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush != null) { + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); + } +} + +fn drawText( + hdc: c.HDC, + allocator: std.mem.Allocator, + text: []const u8, + x: i32, + y: i32, + size: i32, + color: u32, +) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; + defer allocator.free(wide); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = rect(x, y, 1200, y + size + 8); + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); +} diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig new file mode 100644 index 00000000..3916d882 --- /dev/null +++ b/graphcode-windows/src/TerminalSurface.zig @@ -0,0 +1,1793 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; +const WorkspaceLayout = @import("WorkspaceLayout.zig"); +const Tokens = @import("DesignTokens.zig"); + +const columns: usize = 120; +const rows: usize = 40; +const cell_count: usize = columns * rows; + +const ParserState = enum { normal, escape, csi, osc }; +const input_queue_capacity: usize = 64; +const input_queue_max_bytes: usize = 1024 * 1024; +const input_write_timeout_ms: c.DWORD = 50; +const max_surfaces: usize = 32; + +pub const ChromeAction = enum { new_tab, split_right, split_down }; +pub const LoopBarAction = enum { stop, show_graph }; + +pub fn loopBarActionAt(left: i32, top: i32, right: i32, x: i32, y: i32, resolved: bool) ?LoopBarAction { + if (y < top or y >= top + Tokens.loop_bar_height) return null; + if (x >= right - 104 and x < right - 12) return .show_graph; + if (!resolved and x >= right - 196 and x < right - 112) return .stop; + _ = left; + return null; +} + +fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction { + if (y < origin_y + 3 or y >= origin_y + Tokens.tab_bar_height - 3) return null; + const left = @max(origin_x, origin_x + width - 220); + if (x < left or x >= origin_x + width - 4) return null; + return switch (@divTrunc(x - left, 72)) { + 0 => .new_tab, + 1 => .split_right, + 2 => .split_down, + else => null, + }; +} + +pub const WorkspaceKeyCallback = *const fn ( + context: ?*anyopaque, + key: usize, + ctrl: bool, + shift: bool, +) callconv(.c) void; + +pub const InputQueue = struct { + pub const max_bytes = input_queue_max_bytes; + + pub const Item = struct { + surface: usize, + bytes: []u8, + }; + allocator: std.mem.Allocator, + items: [input_queue_capacity]Item = undefined, + head: usize = 0, + count: usize = 0, + bytes: usize = 0, + + pub fn enqueue(self: *InputQueue, surface: usize, bytes: []u8) !void { + if (bytes.len > input_queue_max_bytes) return error.InputTooLarge; + if (self.count == input_queue_capacity or self.bytes + bytes.len > input_queue_max_bytes) { + return error.InputQueueFull; + } + const index = (self.head + self.count) % input_queue_capacity; + self.items[index] = .{ .surface = surface, .bytes = bytes }; + self.count += 1; + self.bytes += bytes.len; + } + + pub fn dequeue(self: *InputQueue) ?Item { + if (self.count == 0) return null; + const item = self.items[self.head]; + self.head = (self.head + 1) % input_queue_capacity; + self.count -= 1; + self.bytes -= item.bytes.len; + return item; + } + + pub fn clear(self: *InputQueue) void { + while (self.dequeue()) |item| self.allocator.free(item.bytes); + } + + pub fn removeSurface(self: *InputQueue, surface: usize) void { + var kept: [input_queue_capacity]Item = undefined; + var kept_count: usize = 0; + while (self.dequeue()) |item| { + if (item.surface == surface) { + self.allocator.free(item.bytes); + } else { + kept[kept_count] = item; + kept_count += 1; + } + } + for (kept[0..kept_count]) |item| { + self.items[self.count] = item; + self.count += 1; + self.bytes += item.bytes.len; + } + self.head = 0; + } +}; + +pub const Surface = struct { + surface: ?*c.winghostty_surface = null, + attach: ?std.process.Child = null, + session_name: []u8 = &.{}, + project_path: []u8 = &.{}, + destroying: bool = false, + destroyed: bool = false, + input_bytes: usize = 0, + output_events: usize = 0, + terminal_buffer: [16 * 1024]u8 = undefined, + terminal_buffer_len: usize = 0, + cells: []c.winghostty_terminal_cell = &.{}, + terminal_x: usize = 0, + terminal_y: usize = 0, + parser: ParserState = .normal, + csi_value: usize = 0, + csi_have_value: bool = false, +}; + +pub fn surfaceIdentityMatches(surface: *const Surface, project_path: []const u8, session: []const u8) bool { + return std.mem.eql(u8, surface.project_path, project_path) and + std.mem.eql(u8, surface.session_name, session); +} + +pub const Workspace = struct { + parent: c.HWND, + host: ?*c.winghostty_host = null, + surfaces: [max_surfaces]Surface = [_]Surface{.{}} ** max_surfaces, + active_surface: usize = 0, + allocator: std.mem.Allocator, + zmx_path: []u8, + cwd: []u8, + recreate_sessions: [max_surfaces][]u8 = [_][]u8{&.{}} ** max_surfaces, + recreate_due_ms: [max_surfaces]i64 = [_]i64{0} ** max_surfaces, + recreate_delay_ms: [max_surfaces]i64 = [_]i64{100} ** max_surfaces, + restore_errors: [max_surfaces][]u8 = [_][]u8{&.{}} ** max_surfaces, + fatal_error: bool = false, + render_error: c.winghostty_result = c.WINGHOSTTY_OK, + input_mutex: std.Thread.Mutex = .{}, + input_condition: std.Thread.Condition = .{}, + input_worker: ?std.Thread = null, + input_stop: bool = false, + input_busy: bool = false, + input_worker_surface: ?usize = null, + input_worker_handle: c.HANDLE = null, + input_cancel_requested: bool = false, + input_queue: InputQueue, + input_error_message: []const u8 = "", + layout: WorkspaceLayout.Layout, + layout_path: []u8, + project_key: []u8, + key_callback: ?WorkspaceKeyCallback = null, + key_callback_context: ?*anyopaque = null, + layout_origin_x: i32 = 0, + layout_origin_y: i32 = 0, + layout_width: i32 = 960, + layout_height: i32 = 250, + project_path: []u8 = &.{}, + syncing_topology: bool = false, + syncing_focus: bool = false, + persisting_layout: bool = false, + + pub fn init(parent: c.HWND, allocator_: std.mem.Allocator) !*Workspace { + const workspace = try allocator_.create(Workspace); + workspace.* = .{ + .parent = parent, + .allocator = allocator_, + .zmx_path = try allocator_.dupe(u8, std.process.getEnvVarOwned(allocator_, "GRAPHCODE_ZMX") catch "zmx.exe"), + .cwd = try allocator_.dupe(u8, std.process.getEnvVarOwned(allocator_, "GRAPHCODE_GATE_CWD") catch "."), + .input_queue = .{ .allocator = allocator_ }, + .layout = try WorkspaceLayout.Layout.init( + allocator_, + std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") + catch "global", + ), + .layout_path = &.{}, + .project_key = try allocator_.dupe( + u8, + std.process.getEnvVarOwned(allocator_, "GRAPHCODE_WORKSPACE_PROJECT") + catch "global", + ), + }; + for (&workspace.surfaces) |*surface| { + surface.cells = try allocator_.alloc(c.winghostty_terminal_cell, cell_count); + for (surface.cells) |*cell| { + cell.* = .{ .codepoint = 0, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; + } + } + errdefer { + for (&workspace.surfaces) |*surface| { + if (surface.cells.len != 0) allocator_.free(surface.cells); + } + allocator_.free(workspace.zmx_path); + allocator_.free(workspace.cwd); + workspace.layout.deinit(); + allocator_.free(workspace.layout_path); + allocator_.free(workspace.project_key); + allocator_.destroy(workspace); + } + workspace.layout_path = try workspace.layoutPathForProject(workspace.project_key); + if (WorkspaceLayout.Layout.load(allocator_, workspace.layout_path, workspace.project_key)) |restored| { + workspace.layout.deinit(); + workspace.layout = restored; + } else |_| {} + if (c.winghostty_host_initialize(&workspace.host) != c.WINGHOSTTY_OK) { + return error.WinghosttyHostInitializeFailed; + } + workspace.restorePersistedSurfaces(); + return workspace; + } + + pub fn startInputWorker(self: *Workspace) !void { + self.input_worker = try std.Thread.spawn(.{}, inputWorkerMain, .{self}); + } + + pub fn deinit(self: *Workspace) void { + self.stopInputWorker(); + for (self.surfaces, 0..) |_, index| self.destroySurface(index); + for (&self.recreate_sessions) |*session| { + if (session.*.len != 0) self.allocator.free(session.*); + session.* = &.{}; + } + for (&self.restore_errors) |*message| { + if (message.*.len != 0) self.allocator.free(message.*); + message.* = &.{}; + } + for (&self.surfaces) |*surface| { + if (surface.cells.len != 0) { + self.allocator.free(surface.cells); + surface.cells = &.{}; + } + } + if (self.project_path.len != 0) self.allocator.free(self.project_path); + if (self.host) |host| { + _ = c.winghostty_host_deinitialize(host); + self.host = null; + } + + self.allocator.free(self.zmx_path); + self.allocator.free(self.cwd); + self.layout.deinit(); + self.allocator.free(self.layout_path); + self.allocator.free(self.project_key); + } + + pub fn setKeyCallback( + self: *Workspace, + context: ?*anyopaque, + callback: ?WorkspaceKeyCallback, + ) void { + self.key_callback_context = context; + self.key_callback = callback; + } + + pub fn setProject(self: *Workspace, project: []const u8) !void { + if (project.len == 0 or std.mem.eql(u8, self.project_key, project)) return; + const new_project_key = try self.allocator.dupe(u8, project); + errdefer self.allocator.free(new_project_key); + const new_layout_path = try self.layoutPathForProject(project); + errdefer self.allocator.free(new_layout_path); + var new_layout = try WorkspaceLayout.Layout.init(self.allocator, project); + errdefer new_layout.deinit(); + if (WorkspaceLayout.Layout.load(self.allocator, new_layout_path, project)) |restored| { + new_layout.deinit(); + new_layout = restored; + } else |_| {} + var old_layout = self.layout; + const old_project_key = self.project_key; + const old_layout_path = self.layout_path; + self.layout = new_layout; + self.project_key = new_project_key; + self.layout_path = new_layout_path; + for (self.surfaces, 0..) |_, index| self.destroySurface(index); + self.clearAllRecreateState(); + old_layout.deinit(); + self.allocator.free(old_project_key); + self.allocator.free(old_layout_path); + for (&self.recreate_due_ms) |*due| due.* = 0; + for (&self.recreate_delay_ms) |*delay| delay.* = 100; + self.restorePersistedSurfaces(); + } + + pub fn rebindProject(self: *Workspace, project_path: []const u8) !bool { + if (project_path.len == 0) return false; + const key_changed = !std.mem.eql(u8, self.project_key, project_path); + const path_changed = !std.mem.eql(u8, self.project_path, project_path); + if (!key_changed and !path_changed) return false; + + const new_project_key = try self.allocator.dupe(u8, project_path); + errdefer self.allocator.free(new_project_key); + const new_project_path = try self.allocator.dupe(u8, project_path); + errdefer self.allocator.free(new_project_path); + const new_layout_path = try self.layoutPathForProject(project_path); + errdefer self.allocator.free(new_layout_path); + var new_layout: WorkspaceLayout.Layout = undefined; + if (key_changed) { + new_layout = try WorkspaceLayout.Layout.init(self.allocator, project_path); + errdefer new_layout.deinit(); + if (WorkspaceLayout.Layout.load(self.allocator, new_layout_path, project_path)) |restored| { + new_layout.deinit(); + new_layout = restored; + } else |_| {} + } else { + new_layout = self.layout; + } + + const old_key = self.project_key; + const old_path = self.project_path; + const old_layout_path = self.layout_path; + var old_layout = self.layout; + self.project_key = new_project_key; + self.project_path = new_project_path; + self.layout_path = new_layout_path; + self.layout = new_layout; + for (self.surfaces, 0..) |_, index| self.destroySurface(index); + self.clearAllRecreateState(); + for (&self.recreate_due_ms) |*due| due.* = 0; + for (&self.recreate_delay_ms) |*delay| delay.* = 100; + if (key_changed) old_layout.deinit(); + self.allocator.free(old_key); + self.allocator.free(old_path); + self.allocator.free(old_layout_path); + self.restorePersistedSurfaces(); + return true; + } + + pub fn projectPath(self: *const Workspace) []const u8 { + return self.project_path; + } + + fn layoutPathForProject(self: *Workspace, project: []const u8) ![]u8 { + const configured = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_WORKSPACE_LAYOUT") catch + try self.allocator.dupe(u8, "graphcode-workspace.json"); + defer self.allocator.free(configured); + const suffix = projectLayoutSuffix(project); + return std.fmt.allocPrint(self.allocator, "{s}.{s}.json", .{ + configured[0 .. if (std.mem.endsWith(u8, configured, ".json")) configured.len - 5 else configured.len], + suffix, + }); + + } + + pub fn openNode(self: *Workspace, index: usize, node_id: []const u8) !void { + if (index >= self.surfaces.len) return error.InvalidSurface; + if (self.surfaces[index].surface != null or self.surfaces[index].attach != null) { + const old_id = try self.allocator.dupe(u8, self.surfaces[index].session_name); + defer self.allocator.free(old_id); + const replacement_index = try self.createAttachedSurface(node_id); + errdefer self.destroySurface(replacement_index); + self.layout.replacePaneID(old_id, node_id) catch |err| { + self.destroySurface(replacement_index); + return err; + }; + self.persistLayout() catch |err| { + self.layout.replacePaneID(node_id, old_id) catch {}; + self.destroySurface(replacement_index); + return err; + }; + self.destroySurface(index); + self.surfaces[index] = self.surfaces[replacement_index]; + self.surfaces[replacement_index] = .{}; + self.syncTopology(); + self.clearRecreateSession(index); + return; + } + self.destroySurface(index); + self.resetSessionState(index); + self.recreate_due_ms[index] = 0; + const session = try self.allocator.dupe(u8, node_id); + errdefer self.allocator.free(session); + try self.startSession(index, session); + if (self.layout.tabs.items.len == 0) { + try self.layout.addTab(node_id, true); + } else if (index > 0 and self.layout.tabs.items.len == 1) { + try self.layout.addTab(node_id, false); + } + try self.persistLayout(); + var options = self.surfaceOptions(index); + const result = c.winghostty_host_create_surface_v2( + self.host, + self.parent, + &options, + &self.surfaces[index].surface, + ); + if (result != c.WINGHOSTTY_OK or self.surfaces[index].surface == null) { + self.waitAttach(index); + return error.WinghosttySurfaceCreateFailed; + } + + self.surfaces[index].session_name = session; + self.surfaces[index].project_path = try self.allocator.dupe(u8, self.project_path); + self.surfaces[index].destroyed = false; + self.surfaces[index].destroying = false; + clearCells(&self.surfaces[index]); + self.resize( + self.layout_origin_x, + self.layout_origin_y, + self.layout_width, + self.layout_height, + ); + self.clearRecreateSession(index); + } + + pub fn newTab(self: *Workspace) !void { + const surface_id = try self.layout.newSurfaceID(); + defer self.allocator.free(surface_id); + const previous_selected = self.layout.selected_tab; + const previous_next_id = self.layout.next_tab_id; + const index = try self.createAttachedSurface(surface_id); + errdefer self.destroySurface(index); + self.layout.addTab(surface_id, false) catch |err| { + self.destroySurface(index); + return err; + }; + self.persistLayout() catch |err| { + _ = self.layout.removePane(surface_id); + self.layout.selected_tab = previous_selected; + self.layout.next_tab_id = previous_next_id; + self.destroySurface(index); + return err; + }; + self.syncTopology(); + } + + fn createAttachedSurface(self: *Workspace, session: []const u8) !usize { + for (self.surfaces, 0..) |slot, index| { + if (slot.surface != null or slot.attach != null) continue; + const owned_session = try self.allocator.dupe(u8, session); + errdefer self.allocator.free(owned_session); + try self.startSession(index, owned_session); + var options = self.surfaceOptions(index); + const result = c.winghostty_host_create_surface_v2( + self.host, + self.parent, + &options, + &self.surfaces[index].surface, + ); + if (result != c.WINGHOSTTY_OK or self.surfaces[index].surface == null) { + self.waitAttach(index); + return error.WinghosttySurfaceCreateFailed; + } + + self.surfaces[index].session_name = owned_session; + self.surfaces[index].project_path = try self.allocator.dupe(u8, self.project_path); + self.surfaces[index].destroyed = false; + self.surfaces[index].destroying = false; + clearCells(&self.surfaces[index]); + self.resize( + self.layout_origin_x, + self.layout_origin_y, + self.layout_width, + self.layout_height, + ); + return index; + } + return error.SurfaceCapacityExceeded; + } + + fn restorePersistedSurfaces(self: *Workspace) void { + var ids: [max_surfaces][]u8 = undefined; + var count: usize = 0; + for (self.layout.tabs.items) |tab| for (tab.panes.items) |pane| { + if (count == ids.len) break; + ids[count] = self.allocator.dupe(u8, pane.id) catch continue; + count += 1; + }; + defer for (ids[0..count]) |id| self.allocator.free(id); + for (ids[0..count]) |id| { + if (self.createAttachedSurface(id)) |index| { + self.clearRestoreError(index); + } else |err| { + self.queueRestoreRetry(id, err); + } + } + self.syncTopology(); + } + + fn queueRestoreRetry(self: *Workspace, session: []const u8, err: anyerror) void { + for (self.surfaces, 0..) |slot, index| { + if (slot.surface == null and slot.attach == null and self.recreate_sessions[index].len == 0) { + self.recreate_sessions[index] = self.allocator.dupe(u8, session) catch &.{}; + self.recreate_due_ms[index] = nowMilliseconds() + self.recreate_delay_ms[index]; + const message = std.fmt.allocPrint(self.allocator, "workspace restore pending: {s}", .{@errorName(err)}) catch return; + self.restore_errors[index] = message; + return; + } + } + } + + fn clearRestoreError(self: *Workspace, index: usize) void { + if (self.restore_errors[index].len != 0) self.allocator.free(self.restore_errors[index]); + self.restore_errors[index] = &.{}; + } + + fn clearRecreateSession(self: *Workspace, index: usize) void { + if (self.recreate_sessions[index].len != 0) self.allocator.free(self.recreate_sessions[index]); + self.recreate_sessions[index] = &.{}; + } + + fn clearAllRecreateState(self: *Workspace) void { + for (&self.recreate_sessions, 0..) |*session, index| { + if (session.*.len != 0) self.allocator.free(session.*); + session.* = &.{}; + self.recreate_due_ms[index] = 0; + self.recreate_delay_ms[index] = 100; + } + for (&self.restore_errors) |*message| { + if (message.*.len != 0) self.allocator.free(message.*); + message.* = &.{}; + } + } + + fn cancelRecreateForID(self: *Workspace, id: []const u8) void { + for (self.recreate_sessions, 0..) |session, index| { + if (std.mem.eql(u8, session, id)) { + self.clearRecreateSession(index); + self.clearRestoreError(index); + } + } + } + + fn closeSurfaceForID(self: *Workspace, id: []const u8) bool { + for (self.surfaces, 0..) |slot, index| { + if (std.mem.eql(u8, slot.session_name, id)) { + self.destroySurface(index); + return true; + } + } + return false; + } + + pub fn splitFocused(self: *Workspace, direction: WorkspaceLayout.Direction) !void { + const surface_id = try self.layout.newSurfaceID(); + defer self.allocator.free(surface_id); + const tab = self.layout.selected() orelse return error.NoTabs; + const previous_focus = tab.focused_pane; + const previous_direction = tab.split_direction; + const index = try self.createAttachedSurface(surface_id); + errdefer self.destroySurface(index); + self.layout.splitFocused(direction, surface_id) catch |err| { + self.destroySurface(index); + return err; + }; + self.persistLayout() catch |err| { + _ = self.layout.removePane(surface_id); + if (self.layout.selected()) |current| { + current.focused_pane = previous_focus; + current.split_direction = previous_direction; + } + self.destroySurface(index); + return err; + }; + self.syncTopology(); + } + + pub fn selectTab(self: *Workspace, index: usize) !void { + try self.layout.selectTab(index); + try self.persistLayout(); + self.syncTopology(); + } + + pub fn selectTabAt(self: *Workspace, x: i32, y: i32) bool { + if (y < self.layout_origin_y or y >= self.layout_origin_y + Tokens.tab_bar_height) return false; + if (x < self.layout_origin_x or x >= self.chromeControlsLeft()) return false; + const index = @as(usize, @intCast(@divTrunc(x - self.layout_origin_x, 120))); + if (index >= self.layout.tabs.items.len) return false; + self.selectTab(index) catch return false; + return true; + } + + pub fn selectNextTab(self: *Workspace) void { + self.layout.selectRelativeTab(1); + self.persistLayout() catch {}; + self.syncTopology(); + } + + pub fn selectPreviousTab(self: *Workspace) void { + self.layout.selectRelativeTab(-1); + self.persistLayout() catch {}; + self.syncTopology(); + } + + pub fn focusNextPane(self: *Workspace) void { + self.layout.focusPane(1) catch {}; + self.syncFocusedPane(); + } + + pub fn focusPreviousPane(self: *Workspace) void { + self.layout.focusPane(-1) catch {}; + self.syncFocusedPane(); + } + + pub fn closeFocusedPane(self: *Workspace) !void { + var record = try self.layout.closeFocusedPane(); + defer record.deinit(self.allocator); + self.persistLayout() catch |err| { + self.layout.restoreClosedPane(&record) catch {}; + return err; + }; + self.cancelRecreateForID(record.id); + _ = self.closeSurfaceForID(record.id); + self.syncTopology(); + } + + pub fn persistLayout(self: *Workspace) !void { + if (self.persisting_layout) return; + self.persisting_layout = true; + defer self.persisting_layout = false; + try self.layout.save(self.layout_path); + } + + pub fn recreate(self: *Workspace, index: usize) !void { + const session = if (self.surfaces[index].session_name.len == 0) return else try self.allocator.dupe(u8, self.surfaces[index].session_name); + defer self.allocator.free(session); + self.destroySurface(index); + try self.openNode(index, session); + } + + pub fn resize(self: *Workspace, origin_x: i32, origin_y: i32, width: i32, height: i32) void { + self.layout_origin_x = origin_x; + self.layout_origin_y = origin_y; + self.layout_width = width; + self.layout_height = height; + self.syncTopology(); + } + + pub fn chromeActionAt(self: *const Workspace, x: i32, y: i32) ?ChromeAction { + return chromeActionForBounds( + self.layout_origin_x, + self.layout_origin_y, + self.layout_width, + x, + y, + ); + } + + fn chromeControlsLeft(self: *const Workspace) i32 { + return @max(self.layout_origin_x, self.layout_origin_x + self.layout_width - 220); + } + + /// Draws only product chrome. Winghostty remains responsible for terminal pixels; + /// keeping this separate prevents renderer/provider lifetimes from leaking into the + /// tab and pane model. + pub fn paintChrome(self: *const Workspace, hdc: c.HDC) void { + const tab_bar = c.RECT{ + .left = self.layout_origin_x, + .top = self.layout_origin_y, + .right = self.layout_origin_x + self.layout_width, + .bottom = self.layout_origin_y + Tokens.tab_bar_height, + }; + + fillRect(hdc, tab_bar, Tokens.workspace_rail); + const controls_left = self.chromeControlsLeft(); + for (self.layout.tabs.items, 0..) |tab, index| { + const left = self.layout_origin_x + @as(i32, @intCast(index)) * 120; + if (left + 112 > controls_left) break; + const bounds = c.RECT{ + .left = left, + .top = tab_bar.top + 4, + .right = left + 112, + .bottom = tab_bar.bottom - 4, + }; + fillRect(hdc, bounds, if (index == self.layout.selected_tab) 0x00345D8C else 0x00262626); + drawUtf8(hdc, tabLabel(tab, index), bounds.left + 8, bounds.top + 5, 11, 0x00E6E6E6); + } + + const labels = [_][]const u8{ "New Tab", "Split R", "Split D" }; + for (labels, 0..) |label, index| { + const left = controls_left + @as(i32, @intCast(index)) * 72; + const bounds = c.RECT{ + .left = left, + .top = tab_bar.top + 3, + .right = left + 68, + .bottom = tab_bar.bottom - 3, + }; + fillRect(hdc, bounds, 0x00262626); + drawUtf8(hdc, label, bounds.left + 7, bounds.top + 5, 10, 0x00D8D8D8); + } + for (self.surfaces, 0..) |slot, index| { + if (slot.surface == null) continue; + const left = self.layout_origin_x + if (index == 0) 0 else @divTrunc(self.layout_width, 2); + const right = if (index == 0 and self.surfaces[1].surface != null) + self.layout_origin_x + @divTrunc(self.layout_width, 2) + else + self.layout_origin_x + self.layout_width; + const pane_top = self.layout_origin_y + Tokens.tab_bar_height; + fillRect(hdc, .{ .left = left, .top = pane_top, .right = right, .bottom = pane_top + Tokens.pane_header_height }, 0x00212124); + const launches_agent = if (self.layout.selectedConst()) |tab| + if (self.paneIndex(slot.session_name)) |pane_index| + pane_index < tab.panes.items.len and tab.panes.items[pane_index].launches_agent + else + false + else + false; + drawUtf8( + hdc, + if (launches_agent) "agent" else "shell", + left + 8, + pane_top + 5, + 10, + if (index == self.active_surface) 0x00E6E6E6 else 0x008A8A8A, + ); + drawUtf8(hdc, "zmx session", left + 54, pane_top + 5, 9, 0x007A7A7A); + if (index == self.active_surface) { + fillRect(hdc, .{ .left = left, .top = pane_top + Tokens.pane_header_height - 2, .right = right, .bottom = pane_top + Tokens.pane_header_height }, Tokens.pane_focus_tint); + } + } + } + + pub fn paintLoopBar( + hdc: c.HDC, + allocator: std.mem.Allocator, + left: i32, + right: i32, + project_name: []const u8, + title: []const u8, + loop_type: []const u8, + state: []const u8, + activity: []const u8, + resolved: bool, + ) void { + const top = Tokens.header_height; + fillRect(hdc, .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, 0x00222226); + fillRect(hdc, .{ + .left = left + 14, + .top = top + 11, + .right = left + 18, + .bottom = top + 35, + }, loopTypeAccent(loop_type)); + drawUtf8(hdc, title, left + 27, top + 7, 13, 0x00F2F2F7); + drawUtf8(hdc, state, left + 190, top + 8, 10, stateAccent(state)); + const live_line = if (activity.len != 0) activity else project_name; + drawUtf8(hdc, live_line, left + 27, top + 25, 10, 0x008E8E93); + if (!resolved) { + fillRect(hdc, .{ .left = right - 196, .top = top + 10, .right = right - 112, .bottom = top + 36 }, 0x00303035); + drawUtf8(hdc, "Stop loop", right - 184, top + 17, 10, 0x00D8D8DC); + } + drawUtf8(hdc, "Show in graph", right - 100, top + 17, 10, 0x008E8E93); + fillRect(hdc, .{ .left = left, .top = top + Tokens.loop_bar_height - 1, .right = right, .bottom = top + Tokens.loop_bar_height }, 0x00131315); + _ = allocator; + } + + pub fn poll(self: *Workspace) void { + for (self.surfaces, 0..) |_, index| self.readAttachOutput(index); + self.pollRecreates(); + } + + pub fn focus(self: *Workspace, index: usize) void { + if (index >= self.surfaces.len) return; + if (self.syncing_focus or self.syncing_topology) return; + self.syncing_focus = true; + defer self.syncing_focus = false; + self.active_surface = index; + for (&self.surfaces, 0..) |*slot, other_index| { + if (slot.surface) |surface| { + _ = c.winghostty_surface_set_focus(surface, if (index == other_index) 1 else 0); + } + + } + self.persistFocusedSurface(index); + } + + fn persistFocusedSurface(self: *Workspace, index: usize) void { + if (index >= self.surfaces.len) return; + const id = self.surfaces[index].session_name; + const tab = self.layout.selected() orelse return; + for (tab.panes.items, 0..) |pane, pane_index| { + if (std.mem.eql(u8, pane.id, id)) { + tab.focused_pane = pane_index; + self.active_surface = index; + if (!self.syncing_topology) self.persistLayout() catch {}; + return; + } + } + } + + fn syncTopology(self: *Workspace) void { + if (self.syncing_topology) return; + self.syncing_topology = true; + defer self.syncing_topology = false; + const selected = self.layout.selected() orelse return; + const pane_count = selected.panes.items.len; + const available_height = @max(1, self.layout_height - Tokens.tab_bar_height - Tokens.pane_header_height); + const available_width = @max(1, self.layout_width); + for (&self.surfaces, 0..) |*slot, index| { + const pane_index = self.paneIndex(slot.session_name); + if (slot.surface == null) continue; + if (pane_index) |position| { + const horizontal = selected.split_direction == .horizontal; + const first = if (horizontal) @divTrunc(available_width * position, pane_count) else 0; + const next = if (horizontal) @divTrunc(available_width * (position + 1), pane_count) + else available_width; + const top = if (horizontal) 0 else @divTrunc(available_height * position, pane_count); + const bottom = if (horizontal) available_height + else @divTrunc(available_height * (position + 1), pane_count); + _ = c.winghostty_surface_set_visible(slot.surface, 1); + const bounds = c.winghostty_rect{ + .x = self.layout_origin_x + @as(i32, @intCast(first)), + .y = self.layout_origin_y + Tokens.tab_bar_height + Tokens.pane_header_height + @as(i32, @intCast(top)), + .width = @intCast(@max(1, next - first)), + .height = @intCast(@max(1, bottom - top)), + }; + _ = c.winghostty_surface_set_bounds(slot.surface, &bounds); + const focused = position == selected.focused_pane; + _ = c.winghostty_surface_set_focus(slot.surface, if (focused) 1 else 0); + if (focused) self.active_surface = index; + } else { + _ = c.winghostty_surface_set_visible(slot.surface, 0); + _ = c.winghostty_surface_set_focus(slot.surface, 0); + } + } + } + + fn syncFocusedPane(self: *Workspace) void { + self.syncTopology(); + self.persistLayout() catch {}; + } + + fn paneIndex(self: *const Workspace, id: []const u8) ?usize { + const selected = self.layout.selectedConst() orelse return null; + for (selected.panes.items, 0..) |pane, index| { + if (std.mem.eql(u8, pane.id, id)) return index; + } + return null; + } + + pub fn send(self: *Workspace, text: []const u8) void { + if (self.active_surface >= self.surfaces.len) return; + self.enqueueInput(self.active_surface, text); + } + + pub fn inputStatus(self: *const Workspace) ?[]const u8 { + const workspace: *Workspace = @constCast(self); + workspace.input_mutex.lock(); + defer workspace.input_mutex.unlock(); + if (workspace.input_error_message.len == 0) return null; + return workspace.input_error_message; + } + + pub fn hasSurface(self: *const Workspace, index: usize) bool { + return index < self.surfaces.len and self.surfaces[index].surface != null; + } + + pub fn hasAttach(self: *const Workspace, index: usize) bool { + return index < self.surfaces.len and self.surfaces[index].attach != null; + } + + pub fn firstLiveSurface(self: *const Workspace) ?usize { + for (self.surfaces, 0..) |slot, index| { + if (slot.surface != null or slot.attach != null) return index; + } + return null; + } + + pub fn surfaceIdentityReady(self: *const Workspace, index: usize, session: []const u8, project: []const u8) bool { + if (index >= self.surfaces.len) return false; + const slot = &self.surfaces[index]; + return (slot.surface != null or slot.attach != null) and + surfaceIdentityMatches(slot, project, session); + } + + pub fn dispatchKeyForTest(self: *Workspace, key: usize, ctrl: bool, shift: bool) void { + if (self.key_callback) |callback| callback(self.key_callback_context, key, ctrl, shift); + } + + pub fn topologyHealthy(self: *const Workspace) bool { + var pane_count: usize = 0; + var occupied: usize = 0; + for (self.layout.tabs.items) |tab| { + if (tab.panes.items.len == 0 or tab.focused_pane >= tab.panes.items.len) return false; + for (tab.panes.items) |pane| { + pane_count += 1; + var found = false; + for (&self.surfaces) |slot| { + if (std.mem.eql(u8, slot.session_name, pane.id) and + (slot.surface != null or slot.attach != null)) + { + if (found) return false; + found = true; + } + } + if (!found) return false; + } + } + for (&self.surfaces) |slot| { + if (slot.surface == null and slot.attach == null) continue; + occupied += 1; + if (slot.session_name.len == 0) return false; + var mapped = false; + for (self.layout.tabs.items) |tab| for (tab.panes.items) |pane| { + if (std.mem.eql(u8, pane.id, slot.session_name)) { + if (mapped) return false; + mapped = true; + } + }; + if (!mapped) return false; + } + return pane_count > 0 and pane_count == occupied; + } + + pub fn tabCount(self: *const Workspace) usize { + return self.layout.tabs.items.len; + } + + pub fn layoutMatches(self: *const Workspace, origin_x: i32, origin_y: i32, width: i32, height: i32) bool { + return self.layout_origin_x == origin_x and + self.layout_origin_y == origin_y and + self.layout_width == width and + self.layout_height == @max(1, height); + } + + pub fn destroySurface(self: *Workspace, index: usize) void { + if (index >= self.surfaces.len) return; + const slot = &self.surfaces[index]; + slot.destroying = true; + self.cancelSurfaceInput(index); + self.waitInputIdle(index); + self.waitAttach(index); + if (slot.surface) |surface| { + _ = c.winghostty_surface_destroy(surface); + slot.surface = null; + slot.destroyed = true; + } + slot.destroying = false; + if (slot.session_name.len != 0) { + self.allocator.free(slot.session_name); + slot.session_name = &.{}; + } + if (slot.project_path.len != 0) { + self.allocator.free(slot.project_path); + slot.project_path = &.{}; + } + self.resetSessionState(index); + } + + fn surfaceOptions(self: *Workspace, index: usize) c.winghostty_surface_options_v2 { + var options: c.winghostty_surface_options_v2 = undefined; + c.winghostty_surface_options_v2_init(&options); + options.bounds.x = if (index == 0) 0 else 480; + options.bounds.y = 0; + options.bounds.width = 480; + options.bounds.height = 240; + options.visible = 1; + options.focus = if (index == self.active_surface) 1 else 0; + options.theme = c.WINGHOSTTY_THEME_DARK; + options.font_scale = 1.0; + options.user_data = @ptrCast(self); + options.callbacks.on_exit = @ptrCast(&onExit); + options.callbacks.on_title = @ptrCast(&onTitle); + options.callbacks.on_cwd = @ptrCast(&onCwd); + options.callbacks.on_bell = @ptrCast(&onBell); + options.callbacks.on_notification = @ptrCast(&onNotification); + options.callbacks.on_redraw = @ptrCast(&onRedraw); + options.callbacks.on_focus = @ptrCast(&onFocus); + options.callbacks.on_fatal_error = @ptrCast(&onFatalError); + options.callbacks.on_dpi_changed = @ptrCast(&onDpiChanged); + options.callbacks.on_metrics_changed = @ptrCast(&onMetricsChanged); + options.callbacks.on_accessibility_selection = @ptrCast(&onAccessibilitySelection); + options.input_callbacks.on_key = @ptrCast(&onKey); + options.input_callbacks.on_text = @ptrCast(&onText); + options.input_callbacks.on_ime_start = @ptrCast(&onImeStart); + options.input_callbacks.on_ime_update = @ptrCast(&onImeUpdate); + options.input_callbacks.on_ime_end = @ptrCast(&onImeEnd); + options.input_callbacks.on_mouse = @ptrCast(&onMouse); + options.input_callbacks.on_selection = @ptrCast(&onSelection); + options.input_callbacks.on_link = @ptrCast(&onLink); + options.input_callbacks.on_paste = @ptrCast(&onPaste); + options.input_callbacks.on_clipboard_read = @ptrCast(&onClipboardRead); + options.input_callbacks.on_clipboard_write = @ptrCast(&onClipboardWrite); + options.input.cell_width = 8; + options.input.cell_height = 16; + options.input.selection_enabled = 1; + options.input.links_enabled = 1; + options.input.paste_protection = 1; + options.input.bracketed_paste = 1; + options.input.keyboard_layout = null; + return options; + } + + fn startSession(self: *Workspace, index: usize, session: []const u8) !void { + const nonreading = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_NONREADING_ATTACH") catch null; + defer if (nonreading) |value| self.allocator.free(value); + var attach_args: [4][]const u8 = undefined; + var attach_len: usize = 3; + if (nonreading != null and std.mem.eql(u8, nonreading.?, "1")) { + attach_args = .{ "pwsh", "-NoProfile", "-Command", "Start-Sleep -Seconds 60" }; + attach_len = 4; + } else { + attach_args[0] = self.zmx_path; + attach_args[1] = "attach"; + attach_args[2] = session; + } + var child = std.process.Child.init(attach_args[0..attach_len], self.allocator); + child.cwd = self.cwd; + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Ignore; + try child.spawn(); + if (child.stdin) |stdin| { + var mode: c.DWORD = c.PIPE_NOWAIT; + _ = c.SetNamedPipeHandleState(stdin.handle, &mode, null, null); + } + self.input_mutex.lock(); + self.surfaces[index].attach = child; + self.input_mutex.unlock(); + } + + fn waitAttach(self: *Workspace, index: usize) void { + self.cancelSurfaceInput(index); + self.waitInputIdle(index); + if (self.surfaces[index].attach) |*child| { + _ = child.kill() catch {}; + _ = child.wait() catch {}; + self.surfaces[index].attach = null; + } + } + + fn enqueueInput(self: *Workspace, index: usize, bytes: []const u8) void { + if (bytes.len == 0) return; + if (bytes.len > input_queue_max_bytes) { + self.setInputError("terminal input queue overflow: paste is too large"); + return; + } + const copy = self.allocator.dupe(u8, bytes) catch { + self.setInputError("terminal input queue allocation failed"); + return; + }; + self.input_mutex.lock(); + if (self.input_stop) { + self.input_mutex.unlock(); + self.allocator.free(copy); + return; + } + self.input_queue.enqueue(index, copy) catch |err| { + self.input_mutex.unlock(); + self.allocator.free(copy); + self.setInputError(switch (err) { + error.InputTooLarge => "terminal input queue overflow: paste is too large", + error.InputQueueFull => "terminal input queue overflow", + }); + return; + }; + self.input_condition.signal(); + self.input_mutex.unlock(); + } + + fn stopInputWorker(self: *Workspace) void { + self.input_mutex.lock(); + self.input_stop = true; + self.input_condition.broadcast(); + self.input_mutex.unlock(); + self.cancelInputIo(); + if (self.input_worker) |worker| worker.join(); + self.input_worker = null; + self.input_mutex.lock(); + self.input_queue.clear(); + self.input_busy = false; + self.input_worker_surface = null; + self.input_worker_handle = null; + self.input_mutex.unlock(); + } + + fn inputWorkerMain(self: *Workspace) void { + while (true) { + self.input_mutex.lock(); + while (self.input_queue.count == 0 and !self.input_stop) { + _ = self.input_condition.timedWait(&self.input_mutex, 25 * std.time.ns_per_ms) catch {}; + } + if (self.input_stop) { + self.input_mutex.unlock(); + break; + } + const item = self.input_queue.dequeue().?; + self.input_busy = true; + self.input_worker_surface = item.surface; + self.input_worker_handle = if (self.surfaces[item.surface].attach) |child| + if (child.stdin) |stdin| stdin.handle else null + else + null; + self.input_cancel_requested = false; + const handle = self.input_worker_handle; + self.input_mutex.unlock(); + + const result = if (handle) |value| + writeInputBounded(value, item.bytes) + else + error.InputUnavailable; + const cancelled = self.inputCancelled(); + if (result) |written| { + self.input_mutex.lock(); + if (item.surface < self.surfaces.len) self.surfaces[item.surface].input_bytes += written; + self.input_mutex.unlock(); + } else |err| { + if (!cancelled) { + self.setInputError(switch (err) { + error.WriteTimeout => "terminal input write timed out", + error.InputUnavailable => "terminal attach input unavailable", + else => "terminal input write failed", + }); + } + } + self.allocator.free(item.bytes); + self.input_mutex.lock(); + self.input_busy = false; + self.input_worker_surface = null; + self.input_worker_handle = null; + self.input_cancel_requested = false; + self.input_condition.broadcast(); + self.input_mutex.unlock(); + } + } + + fn inputCancelled(self: *Workspace) bool { + self.input_mutex.lock(); + defer self.input_mutex.unlock(); + return self.input_cancel_requested or self.input_stop; + } + + fn setInputError(self: *Workspace, message: []const u8) void { + self.input_mutex.lock(); + self.input_error_message = message; + self.fatal_error = true; + self.input_mutex.unlock(); + } + + fn cancelInputIo(self: *Workspace) void { + var handle: c.HANDLE = null; + var thread_handle: std.Thread.Handle = undefined; + var have_thread = false; + self.input_mutex.lock(); + self.input_cancel_requested = true; + handle = self.input_worker_handle; + if (self.input_worker) |worker| { + thread_handle = worker.getHandle(); + have_thread = true; + } + self.input_mutex.unlock(); + if (handle != null and handle != c.INVALID_HANDLE_VALUE) { + _ = c.CancelIoEx(handle, null); + } + if (have_thread) _ = c.CancelSynchronousIo(thread_handle); + } + + fn cancelSurfaceInput(self: *Workspace, index: usize) void { + self.input_mutex.lock(); + self.input_queue.removeSurface(index); + const busy = self.input_busy and self.input_worker_surface == index; + self.input_mutex.unlock(); + if (busy) self.cancelInputIo(); + } + + fn waitInputIdle(self: *Workspace, index: usize) void { + const deadline = nowMilliseconds() + 500; + while (true) { + self.input_mutex.lock(); + const busy = self.input_busy and self.input_worker_surface == index; + if (!busy) { + self.input_mutex.unlock(); + return; + } + _ = self.input_condition.timedWait(&self.input_mutex, 25 * std.time.ns_per_ms) catch {}; + self.input_mutex.unlock(); + if (nowMilliseconds() >= deadline) { + self.cancelInputIo(); + return; + } + } + } + + fn readAttachOutput(self: *Workspace, index: usize) void { + const slot = &self.surfaces[index]; + const child = slot.attach orelse return; + const stdout = child.stdout orelse return; + var available: c.DWORD = 0; + if (c.PeekNamedPipe(@ptrCast(stdout.handle), null, 0, null, &available, null) == 0) { + self.handleAttachExit(index); + return; + } + var budget: usize = 64 * 1024; + while (available > 0 and budget > 0) { + var buffer: [4096]u8 = undefined; + var read: c.DWORD = 0; + const amount = @min( + @min(available, @as(c.DWORD, @intCast(buffer.len))), + @as(c.DWORD, @intCast(budget)), + ); + if (c.ReadFile(@ptrCast(stdout.handle), &buffer, amount, &read, null) == 0 or read == 0) { + self.handleAttachExit(index); + return; + } + self.feedTerminalOutput(index, buffer[0..@intCast(read)]); + budget -= @intCast(read); + if (c.PeekNamedPipe(@ptrCast(stdout.handle), null, 0, null, &available, null) == 0) { + self.handleAttachExit(index); + return; + } + } + if (c.GetExitCodeProcess(child.id, &available) != 0 and available != c.STILL_ACTIVE) { + self.handleAttachExit(index); + } + } + + fn handleAttachExit(self: *Workspace, index: usize) void { + if (index >= self.surfaces.len) return; + const slot = &self.surfaces[index]; + if (slot.attach == null) return; + const session = if (slot.session_name.len == 0) + null + else + self.allocator.dupe(u8, slot.session_name) catch null; + self.waitAttach(index); + self.destroySurface(index); + if (session) |value| { + if (self.recreate_sessions[index].len != 0) self.allocator.free(self.recreate_sessions[index]); + self.recreate_sessions[index] = value; + self.recreate_due_ms[index] = nowMilliseconds() + self.recreate_delay_ms[index]; + self.recreate_delay_ms[index] = @min(self.recreate_delay_ms[index] * 2, 4_000); + } + } + + fn pollRecreates(self: *Workspace) void { + const now = nowMilliseconds(); + for (self.recreate_sessions, 0..) |session, index| { + if (session.len == 0 or self.surfaces[index].surface != null or now < self.recreate_due_ms[index]) { + continue; + } + self.openNode(index, session) catch { + self.recreate_due_ms[index] = now + self.recreate_delay_ms[index]; + self.recreate_delay_ms[index] = @min(self.recreate_delay_ms[index] * 2, 4_000); + continue; + }; + self.clearRestoreError(index); + self.recreate_delay_ms[index] = 100; + } + } + + fn resetSessionState(self: *Workspace, index: usize) void { + const slot = &self.surfaces[index]; + slot.terminal_buffer_len = 0; + slot.parser = .normal; + slot.csi_value = 0; + slot.csi_have_value = false; + clearCells(slot); + slot.input_bytes = 0; + slot.output_events = 0; + } + + fn feedTerminalOutput(self: *Workspace, index: usize, bytes: []const u8) void { + const slot = &self.surfaces[index]; + const surface = slot.surface orelse return; + appendOutput(slot, bytes); + feedCells(slot, bytes); + self.render_error = c.winghostty_surface_set_terminal_cells( + surface, + columns, + rows, + slot.cells.ptr, + cell_count, + ); + _ = c.winghostty_surface_notify_accessibility_text( + surface, + slot.terminal_buffer[0..slot.terminal_buffer_len].ptr, + slot.terminal_buffer_len, + 0, + slot.terminal_buffer_len, + 0, + 0, + slot.terminal_buffer_len, + ); + _ = c.winghostty_surface_notify_redraw(surface); + slot.output_events += 1; + } +}; + +fn writeInputBounded(handle: c.HANDLE, bytes: []const u8) !usize { + var offset: usize = 0; + while (offset < bytes.len) { + const amount: c.DWORD = @intCast(@min(bytes.len - offset, 16 * 1024)); + var overlapped = std.mem.zeroes(c.OVERLAPPED); + overlapped.hEvent = c.CreateEventW(null, 1, 0, null); + if (overlapped.hEvent == null) return error.WriteFailed; + defer _ = c.CloseHandle(overlapped.hEvent); + var written: c.DWORD = 0; + if (c.WriteFile(handle, bytes[offset..].ptr, amount, &written, &overlapped) == 0) { + if (c.GetLastError() != c.ERROR_IO_PENDING) return error.WriteFailed; + const wait_result = c.WaitForSingleObject(overlapped.hEvent, input_write_timeout_ms); + if (wait_result == c.WAIT_TIMEOUT) { + _ = c.CancelIoEx(handle, &overlapped); + waitForCancelledWrite(handle, &overlapped, &written); + return error.WriteTimeout; + } + if (wait_result != c.WAIT_OBJECT_0 or + c.GetOverlappedResult(handle, &overlapped, &written, 0) == 0) + { + return error.WriteFailed; + } + } + if (written == 0) return error.WriteFailed; + offset += written; + } + return offset; +} + +fn waitForCancelledWrite( + handle: c.HANDLE, + overlapped: *c.OVERLAPPED, + written: *c.DWORD, +) void { + if (c.GetOverlappedResult(handle, overlapped, written, 1) != 0) return; + const completion_error = c.GetLastError(); + if (completion_error == c.ERROR_OPERATION_ABORTED or + completion_error == c.ERROR_IO_INCOMPLETE) + { + return; + } +} + +fn nowMilliseconds() i64 { + return @intCast(std.time.milliTimestamp()); +} + +fn projectLayoutSuffix(project: []const u8) [16]u8 { + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(project, &digest, .{}); + var suffix: [16]u8 = undefined; + const value = std.mem.readInt(u64, digest[0..8], .little); + const hex = "0123456789abcdef"; + for (0..16) |index| { + suffix[15 - index] = hex[(value >> @as(u6, @intCast(index * 4))) & 0x0f]; + } + return suffix; +} + +test "project layout suffix is fixed width and deterministic" { + try std.testing.expectEqualStrings("763dc256de57db00", &projectLayoutSuffix("leading-zero-182")); +} + +fn workspaceFromUserData(user_data: ?*anyopaque) ?*Workspace { + return if (user_data) |value| @ptrCast(@alignCast(value)) else null; +} + +fn slotForSurface(workspace: *Workspace, surface: *c.winghostty_surface) ?*Surface { + for (&workspace.surfaces) |*slot| if (slot.surface == surface) return slot; + return null; +} + +fn callbackSlot(workspace: *Workspace, surface: *c.winghostty_surface) ?*Surface { + const slot = slotForSurface(workspace, surface) orelse return null; + if (slot.destroying or slot.destroyed) return null; + return slot; +} + +fn onExit(user_data: ?*anyopaque, surface: ?*c.winghostty_surface, status: i32) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + if (surface) |value| _ = callbackSlot(workspace, value); + _ = status; +} + +fn onTitle(user_data: ?*anyopaque, surface: *c.winghostty_surface, title: [*:0]const u8) callconv(.c) void { + _ = user_data; + _ = surface; + _ = title; +} + +fn onCwd(user_data: ?*anyopaque, surface: *c.winghostty_surface, cwd: [*:0]const u8) callconv(.c) void { + _ = user_data; + _ = surface; + _ = cwd; +} + +fn onBell(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn onNotification(user_data: ?*anyopaque, surface: *c.winghostty_surface, notification: [*:0]const u8) callconv(.c) void { + _ = user_data; + _ = surface; + _ = notification; +} + +fn onRedraw(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + if (c.winghostty_surface_make_current(surface) != c.WINGHOSTTY_OK) return; + _ = c.winghostty_surface_render(surface); + _ = c.winghostty_surface_present(surface); + _ = c.winghostty_surface_clear_current(surface); +} + +fn onFocus(user_data: ?*anyopaque, surface: *c.winghostty_surface, focused: u8) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + if (focused == 0) return; + if (workspace.syncing_topology or workspace.syncing_focus) return; + for (&workspace.surfaces, 0..) |*slot, index| { + if (slot.surface == surface) { + workspace.active_surface = index; + workspace.persistFocusedSurface(index); + } + if (slot.surface) |other| { + if (other != surface) { + _ = c.winghostty_surface_set_focus(other, 0); + } + } + } +} + +fn onFatalError( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + result: c.winghostty_result, + message: [*:0]const u8, +) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + _ = result; + _ = message; + workspace.fatal_error = true; +} + +fn onDpiChanged(user_data: ?*anyopaque, surface: *c.winghostty_surface, dpi: u32, scale: f32) callconv(.c) void { + _ = user_data; + _ = surface; + _ = dpi; + _ = scale; +} + +fn onMetricsChanged(user_data: ?*anyopaque, surface: *c.winghostty_surface, metrics: *const c.winghostty_cell_metrics) callconv(.c) void { + _ = user_data; + _ = surface; + _ = metrics; +} + +fn onAccessibilitySelection(user_data: ?*anyopaque, surface: *c.winghostty_surface, start: u64, end: u64) callconv(.c) void { + _ = user_data; + _ = surface; + _ = start; + _ = end; +} + +fn onKey(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c.winghostty_key_event) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + if (event.action == c.WINGHOSTTY_KEY_RELEASE) return; + const modifiers = callbackModifiers(event.modifiers); + const ctrl = modifiers.ctrl; + const shift = modifiers.shift; + if (isApplicationShortcut(event.virtual_key, ctrl, shift)) + { + if (workspace.key_callback) |callback| + callback(workspace.key_callback_context, event.virtual_key, ctrl, shift); + return; + } + + const bytes: []const u8 = switch (event.virtual_key) { + c.VK_RETURN => "\r", + c.VK_BACK => "\x08", + c.VK_TAB => "\t", + c.VK_ESCAPE => "\x1b", + c.VK_UP => "\x1b[A", + c.VK_DOWN => "\x1b[B", + c.VK_LEFT => "\x1b[D", + c.VK_RIGHT => "\x1b[C", + else => return, + }; + const index = surfaceIndex(workspace, surface) orelse return; + workspace.enqueueInput(index, bytes); +} + +fn isApplicationShortcut(key: usize, ctrl: bool, shift: bool) bool { + _ = shift; + if (key == c.VK_TAB) return true; + if (!ctrl) return false; + return switch (key) { + 'O', 'J', 'N', 'S', 'T', 'W', 'D', c.VK_PRIOR, c.VK_NEXT, 0xDB, 0xDD, 0xBC => true, + else => false, + }; +} + +fn callbackModifiers(mask: u32) struct { ctrl: bool, shift: bool } { + return .{ .ctrl = (mask & 0x02) != 0, .shift = (mask & 0x01) != 0 }; +} + +test "child key callback forwards advertised menu shortcuts only" { + try std.testing.expect(isApplicationShortcut(c.VK_PRIOR, true, false)); + try std.testing.expect(isApplicationShortcut(c.VK_NEXT, true, false)); + try std.testing.expect(isApplicationShortcut(c.VK_TAB, true, false)); + try std.testing.expect(isApplicationShortcut(c.VK_TAB, false, true)); + try std.testing.expect(isApplicationShortcut(0xBC, true, false)); + try std.testing.expect(isApplicationShortcut('W', true, true)); + try std.testing.expect(!isApplicationShortcut(c.VK_UP, false, false)); + try std.testing.expect(!isApplicationShortcut(c.VK_DOWN, false, false)); + try std.testing.expect(!isApplicationShortcut('M', true, false)); +} + +test "child callback preserves actual modifier bits" { + const plain = callbackModifiers(0); + try std.testing.expect(!plain.ctrl); + try std.testing.expect(!plain.shift); + const shifted = callbackModifiers(0x01); + try std.testing.expect(!shifted.ctrl); + try std.testing.expect(shifted.shift); + const controlled = callbackModifiers(0x02); + try std.testing.expect(controlled.ctrl); + try std.testing.expect(!controlled.shift); + const both = callbackModifiers(0x03); + try std.testing.expect(both.ctrl); + try std.testing.expect(both.shift); +} + +fn onText(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + const index = surfaceIndex(workspace, surface) orelse return; + workspace.enqueueInput(index, text[0..length]); +} + +fn onImeStart(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn onImeUpdate(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32, cursor: u32) callconv(.c) void { + _ = user_data; + _ = surface; + _ = text; + _ = length; + _ = cursor; +} + +fn onImeEnd(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn onMouse(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c.winghostty_mouse_event) callconv(.c) void { + _ = user_data; + _ = surface; + _ = event; +} + +fn onSelection(user_data: ?*anyopaque, surface: *c.winghostty_surface, event: *const c.winghostty_selection_event) callconv(.c) void { + _ = user_data; + _ = surface; + _ = event; +} + +fn onLink( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + link: [*:0]const u8, + hovered: u8, + clicked: u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = link; + _ = hovered; + _ = clicked; +} + +fn onPaste(user_data: ?*anyopaque, surface: *c.winghostty_surface, text: [*:0]const u8, length: u32, bracketed: u8) callconv(.c) void { + const workspace = workspaceFromUserData(user_data) orelse return; + _ = callbackSlot(workspace, surface) orelse return; + const index = surfaceIndex(workspace, surface) orelse return; + workspace.enqueueInput(index, text[0..length]); + _ = bracketed; +} + +fn onClipboardRead( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + format: u32, + text: [*:0]const u8, + length: u32, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = format; + _ = text; + _ = length; +} + +fn onClipboardWrite( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + format: u32, + text: [*:0]const u8, + length: u32, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = format; + _ = text; + _ = length; +} + +fn surfaceIndex(workspace: *Workspace, surface: *c.winghostty_surface) ?usize { + for (workspace.surfaces, 0..) |slot, index| if (slot.surface == surface) return index; + return null; +} + +test "surface identity cannot leak a session across project paths" { + const first = Surface{ + .project_path = @constCast("C:\\work\\first"), + .session_name = @constCast("node-1"), + }; + const second = Surface{ + .project_path = @constCast("C:\\work\\second"), + .session_name = @constCast("node-1"), + }; + try std.testing.expect(surfaceIdentityMatches(&first, "C:\\work\\first", "node-1")); + try std.testing.expect(!surfaceIdentityMatches(&second, "C:\\work\\first", "node-1")); +} + +fn fillRect(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush == null) return; + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); +} + +fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; + defer std.heap.page_allocator.free(wide); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = c.RECT{ .left = x, .top = y, .right = x + 220, .bottom = y + size + 8 }; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE); +} + +fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 { + if (tab.panes.items.len > 1) return "split"; + if (index == 0) return "agent"; + return "shell"; +} + +fn loopTypeAccent(loop_type: []const u8) u32 { + if (std.mem.eql(u8, loop_type, "goalBased")) return 0x0048C78E; + if (std.mem.eql(u8, loop_type, "timeBased")) return 0x00D6A649; + if (std.mem.eql(u8, loop_type, "composite")) return 0x00C77DFF; + return 0x007AB8FF; +} + +fn stateAccent(state: []const u8) u32 { + if (std.mem.eql(u8, state, "failed") or std.mem.eql(u8, state, "stalled")) return 0x005F5FFF; + if (std.mem.eql(u8, state, "succeeded")) return 0x006BD58D; + if (std.mem.eql(u8, state, "blocked")) return 0x0049B8FF; + return 0x00C8C8CC; +} + +fn appendOutput(slot: *Surface, bytes: []const u8) void { + if (bytes.len >= slot.terminal_buffer.len) { + @memcpy(&slot.terminal_buffer, bytes[bytes.len - slot.terminal_buffer.len ..]); + slot.terminal_buffer_len = slot.terminal_buffer.len; + return; + } + if (slot.terminal_buffer_len + bytes.len > slot.terminal_buffer.len) { + const overflow = slot.terminal_buffer_len + bytes.len - slot.terminal_buffer.len; + std.mem.copyForwards(u8, slot.terminal_buffer[0 .. slot.terminal_buffer_len - overflow], slot.terminal_buffer[overflow..slot.terminal_buffer_len]); + slot.terminal_buffer_len -= overflow; + } + @memcpy(slot.terminal_buffer[slot.terminal_buffer_len..][0..bytes.len], bytes); + slot.terminal_buffer_len += bytes.len; +} + +fn clearCells(slot: *Surface) void { + for (slot.cells) |*cell| cell.* = .{ .codepoint = 0, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; + slot.terminal_x = 0; + slot.terminal_y = 0; +} + +fn advanceLine(slot: *Surface) void { + slot.terminal_x = 0; + if (slot.terminal_y + 1 < rows) { + slot.terminal_y += 1; + return; + } + std.mem.copyForwards(c.winghostty_terminal_cell, slot.cells[0 .. cell_count - columns], slot.cells[columns..]); + for (slot.cells[cell_count - columns ..]) |*cell| cell.* = .{ .codepoint = 0, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; +} + +fn putCodepoint(slot: *Surface, codepoint: u32) void { + if (slot.terminal_x >= columns) advanceLine(slot); + slot.cells[slot.terminal_y * columns + slot.terminal_x] = .{ .codepoint = codepoint, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; + slot.terminal_x += 1; +} + +fn finishCsi(slot: *Surface, final: u8) void { + const value = if (slot.csi_have_value) slot.csi_value else 1; + switch (final) { + 'A' => slot.terminal_y -|= value, + 'B' => slot.terminal_y = @min(rows - 1, slot.terminal_y + value), + 'C' => slot.terminal_x = @min(columns, slot.terminal_x + value), + 'D' => slot.terminal_x -|= value, + 'J' => if (slot.csi_have_value and slot.csi_value == 2) clearCells(slot), + 'K' => { + const start = slot.terminal_y * columns + slot.terminal_x; + for (slot.cells[start..][0 .. columns - slot.terminal_x]) |*cell| cell.* = .{ .codepoint = 0, .foreground = 0xE6E6E6, .background = 0, .flags = 0 }; + }, + else => {}, + } + slot.csi_value = 0; + slot.csi_have_value = false; +} + +fn feedCells(slot: *Surface, bytes: []const u8) void { + for (bytes) |byte| switch (slot.parser) { + .normal => switch (byte) { + 0x1B => slot.parser = .escape, + '\r' => slot.terminal_x = 0, + '\n' => advanceLine(slot), + '\x08' => slot.terminal_x -|= 1, + '\t' => slot.terminal_x = @min(columns, (slot.terminal_x + 8) & ~@as(usize, 7)), + 0x20...0x7E => putCodepoint(slot, byte), + else => {}, + }, + .escape => switch (byte) { + '[' => { + slot.parser = .csi; + slot.csi_value = 0; + slot.csi_have_value = false; + }, + ']' => slot.parser = .osc, + 'c' => { + clearCells(slot); + slot.parser = .normal; + }, + else => slot.parser = .normal, + }, + .csi => switch (byte) { + '0'...'9' => { + slot.csi_have_value = true; + slot.csi_value = @min(9999, slot.csi_value * 10 + (byte - '0')); + }, + 0x40...0x7E => { + finishCsi(slot, byte); + slot.parser = .normal; + }, + else => {}, + }, + .osc => { + if (byte == 0x07) { + slot.parser = .normal; + } else if (byte == 0x1B) { + slot.parser = .escape; + } + }, + }; +} + +test "terminal input queue rejects large paste without waiting" { + const allocator = std.testing.allocator; + var queue = InputQueue{ .allocator = allocator }; + defer queue.clear(); + const paste = try allocator.alloc(u8, InputQueue.max_bytes + 1); + try std.testing.expectError(error.InputTooLarge, queue.enqueue(0, paste)); + allocator.free(paste); +} + +test "terminal input queue reports bounded overflow" { + const allocator = std.testing.allocator; + var queue = InputQueue{ .allocator = allocator }; + defer queue.clear(); + for (0..input_queue_capacity) |index| { + const item = try allocator.dupe(u8, "x"); + try queue.enqueue(index % 2, item); + } + const overflow = try allocator.dupe(u8, "x"); + try std.testing.expectError(error.InputQueueFull, queue.enqueue(0, overflow)); + allocator.free(overflow); +} + +test "bounded input write rejects an invalid attach without blocking" { + try std.testing.expectError(error.WriteFailed, writeInputBounded(c.INVALID_HANDLE_VALUE, "paste")); +} + +test "workspace chrome actions occupy distinct visible buttons" { + try std.testing.expectEqual(ChromeAction.new_tab, chromeActionForBounds(220, 34, 800, 804, 44).?); + try std.testing.expectEqual(ChromeAction.split_right, chromeActionForBounds(220, 34, 800, 876, 44).?); + try std.testing.expectEqual(ChromeAction.split_down, chromeActionForBounds(220, 34, 800, 948, 44).?); + try std.testing.expectEqual(@as(?ChromeAction, null), chromeActionForBounds(220, 34, 800, 700, 44)); +} + +test "loop bar actions expose stop only for active loops" { + try std.testing.expectEqual(LoopBarAction.stop, loopBarActionAt(220, 34, 1200, 1010, 50, false).?); + try std.testing.expect(loopBarActionAt(220, 34, 1200, 1010, 50, true) == null); + try std.testing.expectEqual(LoopBarAction.show_graph, loopBarActionAt(220, 34, 1200, 1120, 50, false).?); + try std.testing.expect(loopBarActionAt(220, 34, 1200, 1120, 90, false) == null); +} diff --git a/graphcode-windows/src/TerminalWorkspace.zig b/graphcode-windows/src/TerminalWorkspace.zig new file mode 100644 index 00000000..7ab483ee --- /dev/null +++ b/graphcode-windows/src/TerminalWorkspace.zig @@ -0,0 +1,5 @@ +const TerminalSurface = @import("TerminalSurface.zig"); + +pub const Workspace = TerminalSurface.Workspace; +pub const LoopBarAction = TerminalSurface.LoopBarAction; +pub const loopBarActionAt = TerminalSurface.loopBarActionAt; diff --git a/graphcode-windows/src/Tray.zig b/graphcode-windows/src/Tray.zig new file mode 100644 index 00000000..782e7e81 --- /dev/null +++ b/graphcode-windows/src/Tray.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const command_open: c.WPARAM = 0x5001; +pub const command_exit: c.WPARAM = 0x5002; +pub const icon_id: c.UINT = 1; +pub const notify_message: c.UINT = c.WM_APP + 77; +pub const test_callback_wparam: c.WPARAM = 0xC0D1; +pub const test_hook_open: c.WPARAM = 1; +pub const test_hook_context: c.WPARAM = 2; +pub const test_hook_menu: c.WPARAM = 3; +pub var taskbar_created: c.UINT = 0; +pub var test_hook_message: c.UINT = 0; +const test_callback_ack_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.TrayTestCallback"); + +pub const Tray = struct { + hwnd: c.HWND = null, + menu: c.HMENU = null, + added: bool = false, + test_hook_enabled: bool = false, + + pub fn add(self: *Tray, hwnd: c.HWND) !void { + self.hwnd = hwnd; + registerMessages(); + self.menu = c.CreatePopupMenu(); + if (self.menu == null) return error.TrayMenuFailed; + const open = std.unicode.utf8ToUtf16LeStringLiteral("Open GraphCode"); + const exit = std.unicode.utf8ToUtf16LeStringLiteral("Exit"); + if (c.AppendMenuW(self.menu, c.MF_STRING, command_open, open.ptr) == 0 or + c.AppendMenuW(self.menu, c.MF_STRING, command_exit, exit.ptr) == 0) + { + self.remove(); + return error.TrayMenuFailed; + } + var data: c.NOTIFYICONDATAW = std.mem.zeroes(c.NOTIFYICONDATAW); + data.cbSize = @sizeOf(c.NOTIFYICONDATAW); + data.hWnd = hwnd; + data.uID = icon_id; + data.uFlags = c.NIF_MESSAGE | c.NIF_TIP | c.NIF_ICON; + data.uCallbackMessage = notify_message; + data.hIcon = c.LoadIconW(null, @ptrFromInt(32512)); + const tip = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode"); + @memcpy(data.szTip[0..tip.len], tip); + if (c.Shell_NotifyIconW(c.NIM_ADD, &data) == 0) { + self.remove(); + return error.TrayIconFailed; + } + data.unnamed_0.uVersion = c.NOTIFYICON_VERSION_4; + _ = c.Shell_NotifyIconW(c.NIM_SETVERSION, &data); + self.added = true; + } + + pub fn readd(self: *Tray) void { + if (self.hwnd != null) { + self.remove(); + _ = self.add(self.hwnd) catch {}; + } + } + + pub fn remove(self: *Tray) void { + if (self.added) { + var data: c.NOTIFYICONDATAW = std.mem.zeroes(c.NOTIFYICONDATAW); + data.cbSize = @sizeOf(c.NOTIFYICONDATAW); + data.hWnd = self.hwnd; + data.uID = icon_id; + _ = c.Shell_NotifyIconW(c.NIM_DELETE, &data); + self.added = false; + } + if (self.menu != null) _ = c.DestroyMenu(self.menu); + self.menu = null; + if (self.test_hook_enabled) { + _ = c.RemovePropW(self.hwnd, test_callback_ack_property.ptr); + } + } + + pub fn showMenu(self: *Tray) void { + if (self.menu == null) return; + var point: c.POINT = undefined; + _ = c.GetCursorPos(&point); + foregroundWindow(self.hwnd); + const command = c.TrackPopupMenu( + self.menu, + c.TPM_RIGHTALIGN | c.TPM_BOTTOMALIGN | c.TPM_RIGHTBUTTON | c.TPM_RETURNCMD, + point.x, + point.y, + 0, + self.hwnd, + null, + ); + if (command != 0) { + _ = c.PostMessageW(self.hwnd, c.WM_COMMAND, @intCast(command), 0); + } + _ = c.PostMessageW(self.hwnd, c.WM_NULL, 0, 0); + } + + pub fn observeTestCallback(self: *Tray, event: c.UINT, test_callback: bool) void { + if (self.test_hook_enabled and test_callback) { + _ = c.SetPropW(self.hwnd, test_callback_ack_property.ptr, @ptrFromInt(@as(usize, event))); + } + } +}; + +pub fn notificationEvent(lparam: c.LPARAM) c.UINT { + const raw: usize = @bitCast(lparam); + return @intCast(raw & 0xffff); +} + +pub fn callbackTargetsIcon(lparam: c.LPARAM) bool { + const raw: usize = @bitCast(lparam); + const callback_icon_id = (raw >> 16) & 0xffff; + return callback_icon_id == 0 or callback_icon_id == icon_id; +} + +pub fn testNotificationLParam(event: c.UINT) c.LPARAM { + return @intCast((@as(usize, icon_id) << 16) | event); +} + +fn registerMessages() void { + if (taskbar_created == 0) { + taskbar_created = c.RegisterWindowMessageW( + std.unicode.utf8ToUtf16LeStringLiteral("TaskbarCreated").ptr, + ); + } + if (test_hook_message == 0) { + test_hook_message = c.RegisterWindowMessageW( + std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.TrayTestHook").ptr, + ); + } +} + +fn foregroundWindow(hwnd: c.HWND) void { + if (c.SetForegroundWindow(hwnd) == 0) { + const foreground = c.GetForegroundWindow(); + if (foreground != null and foreground != hwnd) { + const current_thread = c.GetCurrentThreadId(); + const foreground_thread = c.GetWindowThreadProcessId(foreground, null); + if (foreground_thread != 0 and foreground_thread != current_thread and + c.AttachThreadInput(current_thread, foreground_thread, 1) != 0) + { + defer _ = c.AttachThreadInput(current_thread, foreground_thread, 0); + _ = c.BringWindowToTop(hwnd); + _ = c.SetForegroundWindow(hwnd); + } + } + } + _ = c.SetFocus(hwnd); +} diff --git a/graphcode-windows/src/Win32.zig b/graphcode-windows/src/Win32.zig new file mode 100644 index 00000000..eb88a1f3 --- /dev/null +++ b/graphcode-windows/src/Win32.zig @@ -0,0 +1,8 @@ +pub const c = @cImport({ + @cDefine("_WIN32_WINNT", "0x0601"); + @cInclude("windows.h"); + @cInclude("shellapi.h"); + @cInclude("winhttp.h"); + @cInclude("sddl.h"); + @cInclude("winghostty/win32_host.h"); +}); diff --git a/graphcode-windows/src/WindowsNativeDialogs.zig b/graphcode-windows/src/WindowsNativeDialogs.zig new file mode 100644 index 00000000..57b4147a --- /dev/null +++ b/graphcode-windows/src/WindowsNativeDialogs.zig @@ -0,0 +1,297 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const Result = struct { + values: [16][]u8, + count: usize, + + pub fn deinit(self: *Result, allocator: std.mem.Allocator) void { + for (self.values[0..self.count]) |value| allocator.free(value); + self.* = undefined; + } +}; + +const State = struct { + allocator: std.mem.Allocator, + parent: c.HWND, + labels: [16][]const u8 = [_][]const u8{""} ** 16, + values: [16][]u8 = [_][]u8{&.{}} ** 16, + label_windows: [16]c.HWND = [_]c.HWND{null} ** 16, + edits: [16]c.HWND = [_]c.HWND{null} ** 16, + description: []const u8 = "", + description_window: c.HWND = null, + count: usize = 0, + scroll_offset: i32 = 0, + accepted: bool = false, + closed: bool = false, + button_y: i32 = 565, +}; + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWindowsDialog"); +const ok_id = 9800; +const cancel_id = 9808; +var active = false; +var active_state: State = undefined; + +pub fn text( + parent: c.HWND, + allocator: std.mem.Allocator, + title: []const u8, + labels: []const []const u8, + initial: []const []const u8, +) !?Result { + return textWithDescription(parent, allocator, title, "", labels, initial); +} + +pub fn textWithDescription( + parent: c.HWND, + allocator: std.mem.Allocator, + title: []const u8, + description: []const u8, + labels: []const []const u8, + initial: []const []const u8, +) !?Result { + if (labels.len == 0 or labels.len > 16 or labels.len != initial.len) return error.InvalidDialogFields; + var state = State{ + .allocator = allocator, + .parent = parent, + .count = labels.len, + .description = description, + }; + for (labels, 0..) |label, index| { + state.labels[index] = label; + state.values[index] = allocator.dupe(u8, initial[index]) catch |err| { + freeStateValues(&state); + return err; + }; + } + + registerClass() catch { + freeStateValues(&state); + return error.DialogClassRegistrationFailed; + }; + const wide_title = wideZ(allocator, title) catch |err| { + freeStateValues(&state); + return err; + }; + defer allocator.free(wide_title); + const window_height: i32 = if (labels.len <= 4) + @as(i32, @intCast(150 + labels.len * 52)) + else + 620; + state.button_y = window_height - 55; + active_state = state; + active_state.closed = false; + active_state.accepted = false; + active = true; + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + class_name.ptr, + wide_title.ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU | c.WS_VSCROLL, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 600, + window_height, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + freeStateValues(&active_state); + active = false; + return error.DialogCreationFailed; + }; + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + var message: c.MSG = undefined; + while (!active_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + active_state.closed = true; + break; + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + active = false; + if (!active_state.accepted) { + freeStateValues(&active_state); + return null; + } + var result = Result{ .values = [_][]u8{&.{}} ** 16, .count = state.count }; + readValues(&active_state); + for (active_state.values[0..state.count], 0..) |value, index| { + result.values[index] = allocator.dupe(u8, value) catch |err| { + result.deinit(allocator); + freeStateValues(&active_state); + return err; + }; + } + freeStateValues(&active_state); + return result; +} + +fn freeStateValues(state: *State) void { + for (state.values[0..state.count]) |value| state.allocator.free(value); + state.values = [_][]u8{&.{}} ** 16; + state.count = 0; +} + +fn registerClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&windowProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = class_name.ptr; + klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.DialogClassRegistrationFailed; +} + +fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_CREATE => { + if (active_state.description.len != 0) createDescription(hwnd, &active_state); + for (active_state.labels[0..active_state.count], 0..) |label, index| { + createField(hwnd, &active_state, label, index); + } + createButton(hwnd, "OK", ok_id, 490, active_state.button_y); + createButton(hwnd, "Cancel", cancel_id, 400, active_state.button_y); + return 0; + }, + c.WM_VSCROLL => { + const action: u16 = @truncate(wparam); + const max_offset: i32 = @max(0, @as(i32, @intCast(active_state.count * 52)) - 510); + switch (action) { + c.SB_LINEUP => active_state.scroll_offset = @max(0, active_state.scroll_offset - 52), + c.SB_LINEDOWN => active_state.scroll_offset = @min(max_offset, active_state.scroll_offset + 52), + c.SB_PAGEUP => active_state.scroll_offset = @max(0, active_state.scroll_offset - 510), + c.SB_PAGEDOWN => active_state.scroll_offset = @min(max_offset, active_state.scroll_offset + 510), + else => {}, + } + repositionFields(); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + if (command == ok_id) { + readValues(&active_state); + active_state.accepted = true; + active_state.closed = true; + return 0; + } + if (command == cancel_id) { + active_state.accepted = false; + active_state.closed = true; + return 0; + } + }, + c.WM_KEYDOWN => { + if (wparam == c.VK_RETURN) { + readValues(&active_state); + active_state.accepted = true; + active_state.closed = true; + return 0; + } + if (wparam == c.VK_ESCAPE) { + active_state.accepted = false; + active_state.closed = true; + return 0; + } + }, + c.WM_CLOSE => { + active_state.accepted = false; + active_state.closed = true; + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn createDescription(hwnd: c.HWND, state: *State) void { + const wide = wideZ(state.allocator, state.description) catch return; + defer state.allocator.free(wide); + state.description_window = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE, + 18, + 12, + 540, + 36, + hwnd, + null, + c.GetModuleHandleW(null), + null, + ); +} + +fn fieldBaseY(state: *const State) usize { + return if (state.description.len == 0) 12 else 56; +} + +fn createField(hwnd: c.HWND, state: *State, label: []const u8, index: usize) void { + const y: i32 = @intCast(fieldBaseY(state) + index * 52); + const wide_label = wideZ(state.allocator, label) catch return; + defer state.allocator.free(wide_label); + state.label_windows[index] = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide_label.ptr, c.WS_CHILD | c.WS_VISIBLE, 18, y, 500, 18, hwnd, null, c.GetModuleHandleW(null), null); + const edit_id: c.HMENU = @ptrFromInt(9904 + index * 8); + const edit = c.CreateWindowExW(c.WS_EX_CLIENTEDGE, std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, null, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL, 18, y + 18, 500, 24, hwnd, edit_id, c.GetModuleHandleW(null), null) orelse return; + state.edits[index] = edit; + const wide_value = wideZ(state.allocator, state.values[index]) catch return; + defer state.allocator.free(wide_value); + _ = c.SetWindowTextW(edit, wide_value.ptr); +} + +fn repositionFields() void { + for (0..active_state.count) |index| { + const y: i32 = @as(i32, @intCast(fieldBaseY(&active_state) + index * 52)) - active_state.scroll_offset; + const visible = y >= 0 and y < 535; + _ = c.ShowWindow(active_state.label_windows[index], if (visible) c.SW_SHOW else c.SW_HIDE); + _ = c.ShowWindow(active_state.edits[index], if (visible) c.SW_SHOW else c.SW_HIDE); + if (visible) { + _ = c.SetWindowPos(active_state.label_windows[index], null, 18, y, 500, 18, c.SWP_NOZORDER); + _ = c.SetWindowPos(active_state.edits[index], null, 18, y + 18, 500, 24, c.SWP_NOZORDER); + } + } +} + +fn createButton(hwnd: c.HWND, label: []const u8, id: usize, x: i32, y: i32) void { + const wide = wideZ(std.heap.c_allocator, label) catch return; + defer std.heap.c_allocator.free(wide); + const button_id: c.HMENU = @ptrFromInt(id); + _ = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_DEFPUSHBUTTON, x, y, 80, 28, hwnd, button_id, c.GetModuleHandleW(null), null); +} + +fn readValues(state: *State) void { + var buffer: [4096]u16 = undefined; + for (0..state.count) |index| { + const length = c.GetWindowTextW(state.edits[index], &buffer, @intCast(buffer.len)); + const value = std.unicode.utf16LeToUtf8Alloc(state.allocator, buffer[0..@intCast(length)]) catch continue; + state.allocator.free(state.values[index]); + state.values[index] = value; + } +} + +fn wideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +test "native dialog field contract preserves Unicode and field count" { + const labels = [_][]const u8{ "URL", "Destination" }; + try std.testing.expectEqual(labels.len, 2); + try std.testing.expect(std.unicode.utf8ValidateSlice("Проекты\\über")); +} diff --git a/graphcode-windows/src/WindowsOnboarding.zig b/graphcode-windows/src/WindowsOnboarding.zig new file mode 100644 index 00000000..923fd3df --- /dev/null +++ b/graphcode-windows/src/WindowsOnboarding.zig @@ -0,0 +1,488 @@ +const std = @import("std"); +const CanvasInput = @import("CanvasInput.zig"); +const c = @import("Win32.zig").c; + +pub const page_count: u8 = 4; + +pub const Backend = enum { + claudeCode, + copilotCLI, + codex, + + pub fn parse(raw: []const u8) Backend { + if (std.mem.eql(u8, raw, "copilotCLI")) return .copilotCLI; + if (std.mem.eql(u8, raw, "codex")) return .codex; + return .claudeCode; + } + + pub fn value(self: Backend) []const u8 { + return switch (self) { + .claudeCode => "claudeCode", + .copilotCLI => "copilotCLI", + .codex => "codex", + }; + } +}; + +pub const Store = struct { + allocator: std.mem.Allocator, + marker: []u8, + + pub fn init(allocator: std.mem.Allocator) !Store { + const base = std.process.getEnvVarOwned(allocator, "LOCALAPPDATA") catch + try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(base); + const dir = try std.fs.path.join(allocator, &.{ base, "GraphCode" }); + errdefer allocator.free(dir); + try std.fs.cwd().makePath(dir); + const marker = try std.fs.path.join(allocator, &.{ dir, "onboarding-seen" }); + allocator.free(dir); + return .{ .allocator = allocator, .marker = marker }; + } + + pub fn deinit(self: *Store) void { + self.allocator.free(self.marker); + self.* = undefined; + } + + pub fn shouldShow(self: Store) bool { + std.fs.cwd().access(self.marker, .{}) catch return true; + return false; + } + + pub fn markSeen(self: Store) !void { + var file = try std.fs.cwd().createFile(self.marker, .{ .truncate = true }); + file.close(); + } +}; + +const State = struct { + allocator: std.mem.Allocator, + page: u8 = 0, + backend: Backend, + closed: bool = false, +}; + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeWindowsOnboarding"); +const title = std.unicode.utf8ToUtf16LeStringLiteral("Welcome to GraphCode"); +const client_width: i32 = 560; +const client_height: i32 = 620; +var active = false; +var active_state: State = undefined; + +pub fn showFirstRun( + parent: c.HWND, + allocator: std.mem.Allocator, + store: Store, + initial_backend: []const u8, +) !?Backend { + if (!store.shouldShow()) return null; + const backend = try show(parent, allocator, initial_backend); + try store.markSeen(); + return backend; +} + +pub fn show(parent: c.HWND, allocator: std.mem.Allocator, initial_backend: []const u8) !Backend { + if (active) return error.OnboardingAlreadyOpen; + try registerClass(); + + var frame = c.RECT{ .left = 0, .top = 0, .right = client_width, .bottom = client_height }; + const style = c.WS_POPUP; + const ex_style = c.WS_EX_DLGMODALFRAME; + _ = c.AdjustWindowRectEx(&frame, style, 0, ex_style); + const width = frame.right - frame.left; + const height = frame.bottom - frame.top; + var owner: c.RECT = undefined; + _ = c.GetWindowRect(parent, &owner); + const x = owner.left + @divTrunc((owner.right - owner.left) - width, 2); + const y = owner.top + @divTrunc((owner.bottom - owner.top) - height, 2); + + active_state = .{ + .allocator = allocator, + .backend = Backend.parse(initial_backend), + }; + active = true; + const hwnd = c.CreateWindowExW( + ex_style, + class_name.ptr, + title.ptr, + style, + x, + y, + width, + height, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + active = false; + return error.OnboardingCreationFailed; + }; + const region = c.CreateRoundRectRgn(0, 0, width, height, 18, 18); + if (region != null and c.SetWindowRgn(hwnd, region, 1) == 0) { + _ = c.DeleteObject(region); + } + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + _ = c.SetFocus(hwnd); + + var message: c.MSG = undefined; + while (!active_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + active_state.closed = true; + break; + } + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + const backend = active_state.backend; + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + active = false; + return backend; +} + +fn registerClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&windowProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = class_name.ptr; + klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + klass.hbrBackground = null; + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.OnboardingClassRegistrationFailed; +} + +fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_ERASEBKGND => return 1, + c.WM_PAINT => { + var paint_state: c.PAINTSTRUCT = undefined; + const hdc = c.BeginPaint(hwnd, &paint_state); + paint(hdc, active_state.allocator, active_state.page, active_state.backend); + _ = c.EndPaint(hwnd, &paint_state); + return 0; + }, + c.WM_LBUTTONUP => { + const point = CanvasInput.decodeMouseMessage(lparam); + handleClick(hwnd, point.x, point.y); + return 0; + }, + c.WM_KEYDOWN => { + switch (wparam) { + c.VK_ESCAPE => active_state.closed = true, + c.VK_LEFT => { + if (active_state.page > 0) active_state.page -= 1; + }, + c.VK_RIGHT, c.VK_RETURN => { + if (active_state.page + 1 < page_count) + active_state.page += 1 + else + active_state.closed = true; + }, + else => return c.DefWindowProcW(hwnd, message, wparam, lparam), + } + _ = c.InvalidateRect(hwnd, null, 0); + return 0; + }, + c.WM_CLOSE => { + active_state.closed = true; + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn handleClick(hwnd: c.HWND, x: i32, y: i32) void { + if (applyClick(&active_state, x, y)) { + _ = c.InvalidateRect(hwnd, null, 0); + } +} + +fn applyClick(state: *State, x: i32, y: i32) bool { + if (inside(x, y, rect(474, 12, 540, 42))) { + state.closed = true; + return false; + } + if (state.page > 0 and inside(x, y, rect(20, 564, 102, 604))) { + state.page -= 1; + return true; + } + if (inside(x, y, rect(418, 564, 540, 604))) { + if (state.page + 1 < page_count) + state.page += 1 + else + state.closed = true; + return true; + } + if (state.page == 3) { + if (inside(x, y, rect(58, 182, 502, 248))) state.backend = .claudeCode; + if (inside(x, y, rect(58, 258, 502, 324))) state.backend = .copilotCLI; + if (inside(x, y, rect(58, 334, 502, 400))) state.backend = .codex; + return true; + } + return false; +} + +fn paint(hdc: c.HDC, allocator: std.mem.Allocator, page: u8, backend: Backend) void { + fill(hdc, rect(0, 0, client_width, client_height), rgb(35, 35, 38)); + text(hdc, allocator, "Skip", rect(474, 16, 540, 40), 13, rgb(160, 160, 166), c.DT_CENTER | c.DT_SINGLELINE, false); + switch (page) { + 0 => paintWelcome(hdc, allocator), + 1 => paintReading(hdc, allocator), + 2 => paintTypes(hdc, allocator), + else => paintBackends(hdc, allocator, backend), + } + paintFooter(hdc, allocator, page); +} + +fn paintWelcome(hdc: c.HDC, allocator: std.mem.Allocator) void { + line(hdc, 160, 112, 342, 174, rgb(112, 112, 118), 2); + line(hdc, 342, 174, 178, 250, rgb(112, 112, 118), 2); + card(hdc, allocator, rect(48, 72, 244, 132), rgb(90, 174, 255), "Watch crash reports", "RUNNING", "/loop 1h - last run 14:02", false); + card(hdc, allocator, rect(276, 144, 472, 204), rgb(123, 210, 130), "Fix the top crash", "RUNNING", "pass 3 - 1.42 -> 1.10", false); + card(hdc, allocator, rect(78, 220, 274, 280), rgb(179, 138, 255), "Review the fix", "NEEDS YOU", "\"Ship this, or split it in two?\"", true); + text(hdc, allocator, "Agents you can watch", rect(32, 330, 528, 370), 25, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, + "Every card is a real terminal session you can open and steer. They hand work to each other along the edges - and tell you when they need you.", + rect(48, 382, 512, 456), 14, rgb(168, 168, 174), c.DT_CENTER | c.DT_WORDBREAK, false); +} + +fn paintReading(hdc: c.HDC, allocator: std.mem.Allocator) void { + card(hdc, allocator, rect(130, 70, 430, 162), rgb(123, 210, 130), "Fix the top crash", "RUNNING", "pass 3 - 1.42 -> 1.10", false); + text(hdc, allocator, "How to read a loop", rect(32, 194, 528, 232), 25, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + lesson(hdc, allocator, 260, "The pill", "What it's doing right now - running, done, blocked, or needs you"); + lesson(hdc, allocator, 304, "The line under it", "Where it's got to, in its own words"); + lesson(hdc, allocator, 348, "The bar", "Progress toward whatever you told it to aim for"); + lesson(hdc, allocator, 392, "The stripe", "Which kind of loop it is - next slide"); + rounded(hdc, rect(127, 458, 433, 490), rgb(61, 49, 31), rgb(61, 49, 31), 12); + text(hdc, allocator, "Amber always means one thing: it's waiting on you.", rect(136, 465, 424, 486), 12, rgb(255, 205, 122), c.DT_CENTER | c.DT_SINGLELINE, true); +} + +fn lesson(hdc: c.HDC, allocator: std.mem.Allocator, y: i32, label: []const u8, description: []const u8) void { + text(hdc, allocator, label, rect(58, y, 188, y + 38), 13, rgb(230, 230, 234), c.DT_RIGHT | c.DT_WORDBREAK, true); + text(hdc, allocator, description, rect(208, y, 506, y + 40), 13, rgb(158, 158, 165), c.DT_LEFT | c.DT_WORDBREAK, false); +} + +fn paintTypes(hdc: c.HDC, allocator: std.mem.Allocator) void { + text(hdc, allocator, "Four kinds of loop", rect(32, 68, 528, 106), 25, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, "They differ in what makes them stop.", rect(32, 108, 528, 134), 14, rgb(168, 168, 174), c.DT_CENTER | c.DT_SINGLELINE, false); + typeTile(hdc, allocator, rect(58, 158, 270, 226), rgb(123, 210, 130), "Goal", "Stops when the goal is met"); + typeTile(hdc, allocator, rect(290, 158, 502, 226), rgb(179, 138, 255), "Turn", "Stops after a fixed number of turns"); + typeTile(hdc, allocator, rect(58, 240, 270, 308), rgb(90, 174, 255), "Time", "Stops when its time budget expires"); + typeTile(hdc, allocator, rect(290, 240, 502, 308), rgb(255, 166, 82), "Composite", "Stops when its child work is done"); + line(hdc, 58, 342, 502, 342, rgb(63, 63, 68), 1); + card(hdc, allocator, rect(76, 370, 244, 424), rgb(90, 174, 255), "Find bugs", "RUNNING", "/loop 1h", false); + line(hdc, 250, 397, 306, 397, rgb(112, 112, 118), 2); + card(hdc, allocator, rect(314, 370, 482, 424), rgb(123, 210, 130), "Fix them", "IDLE", "waiting on the hand-off", false); + text(hdc, allocator, + "Wire them together by dragging from a card's + handle. Click + without dragging to grow a new loop already connected to it.", + rect(58, 446, 502, 512), 13, rgb(158, 158, 165), c.DT_CENTER | c.DT_WORDBREAK, false); +} + +fn paintBackends(hdc: c.HDC, allocator: std.mem.Allocator, selected: Backend) void { + text(hdc, allocator, "Which agent runs them", rect(32, 64, 528, 102), 25, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, "The default for new loops. Change it any time in Settings, or per loop.", rect(52, 108, 508, 150), 14, rgb(168, 168, 174), c.DT_CENTER | c.DT_WORDBREAK, false); + backendRow(hdc, allocator, rect(58, 182, 502, 248), .claudeCode, selected, "Claude Code", "Anthropic's agent - the reference backend, fully wired.", true); + backendRow(hdc, allocator, rect(58, 258, 502, 324), .copilotCLI, selected, "Copilot CLI", "GitHub's agent CLI.", false); + backendRow(hdc, allocator, rect(58, 334, 502, 400), .codex, selected, "Codex", "OpenAI's agent CLI.", false); + text(hdc, allocator, "The CLI must be installed and on your PATH - GraphCode launches it, it doesn't bundle it.", rect(72, 434, 488, 480), 12, rgb(126, 126, 133), c.DT_CENTER | c.DT_WORDBREAK, false); +} + +fn backendRow( + hdc: c.HDC, + allocator: std.mem.Allocator, + bounds: c.RECT, + backend: Backend, + selected: Backend, + name: []const u8, + blurb: []const u8, + hosts_all: bool, +) void { + const is_selected = backend == selected; + rounded(hdc, bounds, if (is_selected) rgb(29, 51, 73) else rgb(43, 43, 47), if (is_selected) rgb(10, 132, 255) else rgb(64, 64, 69), 12); + rounded(hdc, rect(bounds.left + 14, bounds.top + 17, bounds.left + 48, bounds.top + 51), rgb(54, 54, 59), rgb(54, 54, 59), 8); + text(hdc, allocator, ">_", rect(bounds.left + 18, bounds.top + 25, bounds.left + 45, bounds.top + 46), 13, rgb(170, 170, 177), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, name, rect(bounds.left + 62, bounds.top + 11, bounds.left + 205, bounds.top + 32), 13, rgb(240, 240, 243), c.DT_LEFT | c.DT_SINGLELINE, true); + const badge = if (hosts_all) "HOSTS EVERY LOOP KIND" else "LIMITED LOOP TYPES"; + rounded(hdc, rect(bounds.left + 208, bounds.top + 10, bounds.left + 348, bounds.top + 29), if (hosts_all) rgb(38, 66, 46) else rgb(70, 57, 36), if (hosts_all) rgb(38, 66, 46) else rgb(70, 57, 36), 5); + text(hdc, allocator, badge, rect(bounds.left + 212, bounds.top + 13, bounds.left + 344, bounds.top + 27), 9, if (hosts_all) rgb(126, 228, 155) else rgb(255, 205, 122), c.DT_CENTER | c.DT_SINGLELINE, true); + text(hdc, allocator, blurb, rect(bounds.left + 62, bounds.top + 36, bounds.right - 44, bounds.top + 56), 12, rgb(154, 154, 161), c.DT_LEFT | c.DT_SINGLELINE, false); + text(hdc, allocator, if (is_selected) "●" else "○", rect(bounds.right - 38, bounds.top + 21, bounds.right - 12, bounds.top + 47), 18, if (is_selected) rgb(10, 132, 255) else rgb(110, 110, 116), c.DT_CENTER | c.DT_SINGLELINE, false); +} + +fn paintFooter(hdc: c.HDC, allocator: std.mem.Allocator, page: u8) void { + if (page > 0) { + rounded(hdc, rect(20, 564, 102, 604), rgb(48, 48, 52), rgb(78, 78, 84), 9); + text(hdc, allocator, "Back", rect(20, 576, 102, 598), 13, rgb(230, 230, 234), c.DT_CENTER | c.DT_SINGLELINE, false); + } + const primary = page + 1 == page_count; + rounded(hdc, rect(418, 564, 540, 604), if (primary) rgb(10, 132, 255) else rgb(48, 48, 52), if (primary) rgb(10, 132, 255) else rgb(78, 78, 84), 9); + text(hdc, allocator, if (primary) "Get Started" else "Continue", rect(418, 576, 540, 598), 13, rgb(245, 245, 247), c.DT_CENTER | c.DT_SINGLELINE, true); + var x: i32 = 254; + for (0..page_count) |index| { + const color = if (index == page) rgb(10, 132, 255) else rgb(92, 92, 98); + const brush = c.CreateSolidBrush(color); + if (brush != null) { + const old = c.SelectObject(hdc, brush); + _ = c.Ellipse(hdc, x, 580, x + 7, 587); + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(brush); + } + x += 16; + } +} + +fn typeTile(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT, accent: u32, name: []const u8, detail: []const u8) void { + rounded(hdc, bounds, rgb(43, 43, 47), rgb(64, 64, 69), 10); + fill(hdc, rect(bounds.left, bounds.top, bounds.left + 4, bounds.bottom), accent); + text(hdc, allocator, name, rect(bounds.left + 16, bounds.top + 12, bounds.right - 10, bounds.top + 34), 14, rgb(240, 240, 243), c.DT_LEFT | c.DT_SINGLELINE, true); + text(hdc, allocator, detail, rect(bounds.left + 16, bounds.top + 38, bounds.right - 10, bounds.bottom - 6), 11, rgb(154, 154, 161), c.DT_LEFT | c.DT_WORDBREAK, false); +} + +fn card(hdc: c.HDC, allocator: std.mem.Allocator, bounds: c.RECT, accent: u32, heading: []const u8, state: []const u8, subline: []const u8, attention: bool) void { + rounded(hdc, bounds, if (attention) rgb(55, 43, 31) else rgb(39, 39, 44), if (attention) rgb(143, 96, 41) else rgb(61, 61, 67), 10); + fill(hdc, rect(bounds.left, bounds.top, bounds.left + 4, bounds.bottom), accent); + text(hdc, allocator, heading, rect(bounds.left + 12, bounds.top + 9, bounds.right - 68, bounds.top + 29), 12, rgb(242, 242, 245), c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS, true); + text(hdc, allocator, state, rect(bounds.right - 70, bounds.top + 10, bounds.right - 8, bounds.top + 28), 9, if (attention) rgb(255, 205, 122) else rgb(164, 222, 174), c.DT_RIGHT | c.DT_SINGLELINE, true); + text(hdc, allocator, subline, rect(bounds.left + 12, bounds.top + 35, bounds.right - 8, bounds.bottom - 6), 10, rgb(145, 145, 153), c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS, false); +} + +fn text( + hdc: c.HDC, + allocator: std.mem.Allocator, + value: []const u8, + bounds_value: c.RECT, + size: i32, + color: u32, + format: c.UINT, + bold: bool, +) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, value) catch return; + defer allocator.free(wide); + const face = std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI"); + const font = c.CreateFontW( + -size, + 0, + 0, + 0, + if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, + 0, + 0, + 0, + c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, + c.CLIP_DEFAULT_PRECIS, + c.CLEARTYPE_QUALITY, + c.DEFAULT_PITCH | c.FF_DONTCARE, + face.ptr, + ); + const old_font = if (font != null) c.SelectObject(hdc, font) else null; + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = bounds_value; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); + if (font != null) { + _ = c.SelectObject(hdc, old_font); + _ = c.DeleteObject(font); + } +} + +fn rounded(hdc: c.HDC, bounds: c.RECT, fill_color: u32, border_color: u32, radius: i32) void { + const brush = c.CreateSolidBrush(fill_color); + const pen = c.CreatePen(c.PS_SOLID, 1, border_color); + if (brush == null or pen == null) { + if (brush != null) _ = c.DeleteObject(brush); + if (pen != null) _ = c.DeleteObject(pen); + return; + } + const old_brush = c.SelectObject(hdc, brush); + const old_pen = c.SelectObject(hdc, pen); + _ = c.RoundRect(hdc, bounds.left, bounds.top, bounds.right, bounds.bottom, radius, radius); + _ = c.SelectObject(hdc, old_pen); + _ = c.SelectObject(hdc, old_brush); + _ = c.DeleteObject(pen); + _ = c.DeleteObject(brush); +} + +fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush == null) return; + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); +} + +fn line(hdc: c.HDC, x1: i32, y1: i32, x2: i32, y2: i32, color: u32, width: i32) void { + const pen = c.CreatePen(c.PS_SOLID, width, color); + if (pen == null) return; + const old = c.SelectObject(hdc, pen); + _ = c.MoveToEx(hdc, x1, y1, null); + _ = c.LineTo(hdc, x2, y2); + _ = c.SelectObject(hdc, old); + _ = c.DeleteObject(pen); +} + +fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { + return .{ .left = left, .top = top, .right = right, .bottom = bottom }; +} + +fn inside(x: i32, y: i32, bounds: c.RECT) bool { + return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; +} + +fn rgb(red: u8, green: u8, blue: u8) u32 { + return @as(u32, red) | (@as(u32, green) << 8) | (@as(u32, blue) << 16); +} + +test "Windows onboarding matches the four-page macOS contract" { + try std.testing.expectEqual(@as(u8, 4), page_count); +} + +test "onboarding click flow navigates every page and selects a backend" { + var state = State{ .allocator = std.testing.allocator, .backend = .claudeCode }; + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expectEqual(@as(u8, 1), state.page); + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expectEqual(@as(u8, 3), state.page); + try std.testing.expect(applyClick(&state, 200, 280)); + try std.testing.expectEqual(Backend.copilotCLI, state.backend); + try std.testing.expect(applyClick(&state, 50, 580)); + try std.testing.expectEqual(@as(u8, 2), state.page); + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expect(applyClick(&state, 500, 580)); + try std.testing.expect(state.closed); +} + +test "onboarding skip closes immediately" { + var state = State{ .allocator = std.testing.allocator, .backend = .claudeCode }; + try std.testing.expect(!applyClick(&state, 500, 20)); + try std.testing.expect(state.closed); +} + +test "onboarding marker persists first-run completion" { + const marker = "graphcode-onboarding-marker-test"; + std.fs.cwd().deleteFile(marker) catch {}; + defer std.fs.cwd().deleteFile(marker) catch {}; + const store = Store{ .allocator = std.testing.allocator, .marker = @constCast(marker) }; + try std.testing.expect(store.shouldShow()); + try store.markSeen(); + try std.testing.expect(!store.shouldShow()); +} + +test "onboarding backend values match product settings" { + try std.testing.expectEqual(Backend.copilotCLI, Backend.parse("copilotCLI")); + try std.testing.expectEqualStrings("codex", Backend.codex.value()); + try std.testing.expectEqual(Backend.claudeCode, Backend.parse("unknown")); +} + +test "first-run marker defaults to showing when absent" { + const store = Store{ .allocator = std.testing.allocator, .marker = @constCast("definitely-not-present") }; + try std.testing.expect(store.shouldShow()); +} diff --git a/graphcode-windows/src/WindowsProductSettings.zig b/graphcode-windows/src/WindowsProductSettings.zig new file mode 100644 index 00000000..a8a6e281 --- /dev/null +++ b/graphcode-windows/src/WindowsProductSettings.zig @@ -0,0 +1,772 @@ +const std = @import("std"); +const CanvasInput = @import("CanvasInput.zig"); +const c = @import("Win32.zig").c; + +pub const Settings = struct { + allocator: std.mem.Allocator, + default_backend: []u8, + default_model: []u8, + claude_permissions: []u8, + copilot_permissions: []u8, + codex_approvals: []u8, + activity: bool = false, + briefing: bool = true, + beta: bool = false, + auto_selects_model: bool = false, + + pub fn init(allocator: std.mem.Allocator) !Settings { + return parse(allocator, &.{}); + } + + pub fn deinit(self: *Settings) void { + self.allocator.free(self.default_backend); + self.allocator.free(self.default_model); + self.allocator.free(self.claude_permissions); + self.allocator.free(self.copilot_permissions); + self.allocator.free(self.codex_approvals); + self.* = undefined; + } + + pub fn fields(self: Settings) [9][]const u8 { + return .{ + self.default_backend, self.default_model, self.claude_permissions, + self.copilot_permissions, self.codex_approvals, + if (self.activity) "on" else "off", if (self.briefing) "on" else "off", + if (self.beta) "on" else "off", if (self.auto_selects_model) "on" else "off", + }; + } + + pub fn parse(allocator: std.mem.Allocator, values: []const []const u8) !Settings { + const backend = if (values.len > 0 and values[0].len != 0) values[0] else "claudeCode"; + const model = if (values.len > 1 and values[1].len != 0) values[1] else "standard"; + const claude = if (values.len > 2 and values[2].len != 0) values[2] else "auto"; + const copilot = if (values.len > 3 and values[3].len != 0) values[3] else "allowEverything"; + const codex = if (values.len > 4 and values[4].len != 0) values[4] else "workspace"; + if (!isOneOf(backend, &.{ "claudeCode", "copilotCLI", "codex" }) or + !isOneOf(model, &.{ "fast", "standard", "capable" }) or + !isOneOf(claude, &.{ "manual", "acceptEdits", "auto", "dontAsk", "bypassPermissions" }) or + !isOneOf(copilot, &.{ "ask", "allowTools", "allowEverything" }) or + !isOneOf(codex, &.{ "ask", "workspace", "unsandboxed" })) + return error.InvalidSettingValue; + return .{ + .allocator = allocator, + .default_backend = try allocator.dupe(u8, backend), + .default_model = try allocator.dupe(u8, model), + .claude_permissions = try allocator.dupe(u8, claude), + .copilot_permissions = try allocator.dupe(u8, copilot), + .codex_approvals = try allocator.dupe(u8, codex), + .activity = values.len > 5 and std.mem.eql(u8, values[5], "on"), + .briefing = values.len <= 6 or std.mem.eql(u8, values[6], "on"), + .beta = values.len > 7 and std.mem.eql(u8, values[7], "on"), + .auto_selects_model = values.len > 8 and std.mem.eql(u8, values[8], "on"), + }; + } +}; + +fn isOneOf(value: []const u8, choices: []const []const u8) bool { + for (choices) |choice| if (std.mem.eql(u8, value, choice)) return true; + return false; +} + +pub const Store = struct { + allocator: std.mem.Allocator, + path: []u8, + load_failed: bool = false, + + pub fn init(allocator: std.mem.Allocator) !Store { + const base = resolveSupportDirectory(allocator) catch blk: { + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + const result = try std.fs.path.join(allocator, &.{ profile, ".graphcode" }); + allocator.free(profile); + break :blk result; + }; + defer allocator.free(base); + try std.fs.cwd().makePath(base); + const path = try std.fs.path.join(allocator, &.{ base, "settings.json" }); + return .{ .allocator = allocator, .path = path }; + } + + pub fn deinit(self: *Store) void { + self.allocator.free(self.path); + self.* = undefined; + } + + pub fn load(self: *Store) !Settings { + const data = std.fs.cwd().readFileAlloc(self.allocator, self.path, 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => { + self.load_failed = false; + return Settings.init(self.allocator); + }, + else => { + self.load_failed = true; + return err; + }, + }; + defer self.allocator.free(data); + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const value = std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), data, .{}) catch |err| { + self.load_failed = true; + return err; + }; + if (value != .object) { + self.load_failed = true; + return error.SettingsRootMustBeObject; + } + const stringValue = struct { + fn get(object: std.json.ObjectMap, name: []const u8, fallback: []const u8) []const u8 { + return if (object.get(name)) |item| if (item == .string) item.string else fallback else fallback; + } + fn boolean(object: std.json.ObjectMap, name: []const u8, fallback: bool) bool { + return if (object.get(name)) |item| if (item == .bool) item.bool else fallback else fallback; + } + }; + const object = value.object; + const settings = Settings.parse(self.allocator, &.{ + stringValue.get(object, "defaultBackend", "claudeCode"), + stringValue.get(object, "defaultModelTier", "standard"), + stringValue.get(object, "claudePermissionMode", "auto"), + stringValue.get(object, "copilotPermissions", "allowEverything"), + stringValue.get(object, "codexApprovals", "workspace"), + if (stringValue.boolean(object, "showsActivityStrip", false)) "on" else "off", + if (stringValue.boolean(object, "briefsSessionsAboutTheGraph", true)) "on" else "off", + if (stringValue.boolean(object, "betaUpdates", false)) "on" else "off", + if (stringValue.boolean(object, "autoSelectsModel", false)) "on" else "off", + }) catch |err| { + self.load_failed = true; + return err; + }; + self.load_failed = false; + return settings; + } + + pub fn save(self: Store, settings: Settings) !void { + if (self.load_failed) return error.SettingsLoadFailed; + const existing = std.fs.cwd().readFileAlloc(self.allocator, self.path, 1024 * 1024) catch null; + defer if (existing) |data| self.allocator.free(data); + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + var value = if (existing) |data| + try std.json.parseFromSliceLeaky(std.json.Value, arena_allocator, data, .{}) + else + try std.json.parseFromSliceLeaky(std.json.Value, arena_allocator, "{}", .{}); + if (value != .object) return error.SettingsRootMustBeObject; + try value.object.ensureTotalCapacity(value.object.count() + 9); + try value.object.put("defaultBackend", .{ .string = settings.default_backend }); + try value.object.put("defaultModelTier", .{ .string = settings.default_model }); + try value.object.put("claudePermissionMode", .{ .string = settings.claude_permissions }); + try value.object.put("copilotPermissions", .{ .string = settings.copilot_permissions }); + try value.object.put("codexApprovals", .{ .string = settings.codex_approvals }); + try value.object.put("briefsSessionsAboutTheGraph", .{ .bool = settings.briefing }); + try value.object.put("autoSelectsModel", .{ .bool = settings.auto_selects_model }); + try value.object.put("showsActivityStrip", .{ .bool = settings.activity }); + try value.object.put("betaUpdates", .{ .bool = settings.beta }); + const data = try std.fmt.allocPrint(self.allocator, "{f}", .{std.json.fmt(value, .{})}); + defer self.allocator.free(data); + const temp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp-{d}", .{ self.path, std.time.nanoTimestamp() }); + defer self.allocator.free(temp_path); + var file = try std.fs.cwd().createFile(temp_path, .{ .truncate = true }); + try file.writeAll(data); + file.close(); + try atomicReplace(self.allocator, temp_path, self.path); + } +}; + +fn resolveSupportDirectory(allocator: std.mem.Allocator) ![]u8 { + const raw = std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR") catch { + const home = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, ".graphcode" }); + }; + defer allocator.free(raw); + const value = std.mem.trim(u8, raw, " \t\r\n"); + if (value.len == 0) { + const home = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, ".graphcode" }); + } + if (std.mem.eql(u8, value, "~") or std.mem.startsWith(u8, value, "~\\") or std.mem.startsWith(u8, value, "~/")) { + const home = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(home); + const suffix = std.mem.trimLeft(u8, value[1..], "/\\"); + return if (suffix.len == 0) allocator.dupe(u8, home) + else std.fs.path.join(allocator, &.{ home, suffix }); + } + if (!std.fs.path.isAbsolute(value)) { + const home = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(home); + return std.fs.path.join(allocator, &.{ home, value }); + } + return allocator.dupe(u8, value); +} + +fn atomicReplace(allocator: std.mem.Allocator, temp_path: []const u8, target_path: []const u8) !void { + _ = allocator; + try std.os.windows.MoveFileEx( + temp_path, + target_path, + std.os.windows.MOVEFILE_REPLACE_EXISTING | std.os.windows.MOVEFILE_WRITE_THROUGH, + ); +} + +const Contract = struct { + defaultBackend: ?[]const u8 = null, + defaultModelTier: ?[]const u8 = null, + claudePermissionMode: ?[]const u8 = null, + copilotPermissions: ?[]const u8 = null, + codexApprovals: ?[]const u8 = null, + briefsSessionsAboutTheGraph: ?bool = null, + autoSelectsModel: ?bool = null, + showsActivityStrip: ?bool = null, + betaUpdates: ?bool = null, +}; + +pub fn open(parent_address: usize, allocator: std.mem.Allocator, current: Settings) !?Settings { + const parent = windowHandle(parent_address); + if (settings_active) return error.SettingsAlreadyOpen; + try registerSettingsClass(); + settings_state = .{ + .allocator = allocator, + .backend = choiceIndex(current.default_backend, backend_values[0..]), + .model = choiceIndex(current.default_model, model_values[0..]), + .claude = choiceIndex(current.claude_permissions, claude_values[0..]), + .copilot = choiceIndex(current.copilot_permissions, copilot_values[0..]), + .codex = choiceIndex(current.codex_approvals, codex_values[0..]), + .activity = current.activity, + .briefing = current.briefing, + .beta = current.beta, + .auto_model = current.auto_selects_model, + }; + settings_active = true; + + var frame = c.RECT{ .left = 0, .top = 0, .right = settings_width, .bottom = settings_height }; + const style = c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU; + const ex_style = c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT; + _ = c.AdjustWindowRectEx(&frame, style, 0, ex_style); + const width = frame.right - frame.left; + const height = frame.bottom - frame.top; + var owner: c.RECT = undefined; + _ = c.GetWindowRect(parent, &owner); + const hwnd = c.CreateWindowExW( + ex_style, + settings_class.ptr, + settings_title.ptr, + style, + owner.left + @divTrunc((owner.right - owner.left) - width, 2), + owner.top + @divTrunc((owner.bottom - owner.top) - height, 2), + width, + height, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + settings_active = false; + return error.SettingsCreationFailed; + }; + const safe_hwnd = windowHandle(@intFromPtr(hwnd.?)); + createSettingsControls(safe_hwnd); + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + _ = c.SetFocus(hwnd); + var message: c.MSG = undefined; + while (!settings_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + settings_state.closed = true; + break; + } + if (message.message == c.WM_KEYDOWN and message.wParam == c.VK_RETURN) { + settings_state.accepted = true; + settings_state.closed = true; + continue; + } + if (message.message == c.WM_KEYDOWN and message.wParam == c.VK_ESCAPE) { + settings_state.closed = true; + continue; + } + if (c.IsDialogMessageW(hwnd, &message) == 0) { + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + } + const accepted = settings_state.accepted; + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + settings_active = false; + if (!accepted) return null; + return @as(?Settings, try Settings.parse(allocator, &.{ + backend_values[settings_state.backend], + model_values[settings_state.model], + claude_values[settings_state.claude], + copilot_values[settings_state.copilot], + codex_values[settings_state.codex], + if (settings_state.activity) "on" else "off", + if (settings_state.briefing) "on" else "off", + if (settings_state.beta) "on" else "off", + if (settings_state.auto_model) "on" else "off", + })); +} + +const backend_values = [_][]const u8{ "claudeCode", "copilotCLI", "codex" }; +const model_values = [_][]const u8{ "fast", "standard", "capable" }; +const claude_values = [_][]const u8{ "manual", "acceptEdits", "auto", "dontAsk", "bypassPermissions" }; +const copilot_values = [_][]const u8{ "ask", "allowTools", "allowEverything" }; +const codex_values = [_][]const u8{ "ask", "workspace", "unsandboxed" }; +const backend_labels = [_][]const u8{ "Claude Code", "Copilot CLI", "Codex" }; +const model_labels = [_][]const u8{ "Fast", "Standard", "Capable" }; +const claude_labels = [_][]const u8{ "Ask every time", "Accept file edits", "Auto (recommended)", "Don't ask", "Bypass all checks" }; +const copilot_labels = [_][]const u8{ "Ask every time", "Allow tools only", "YOLO (recommended)" }; +const codex_labels = [_][]const u8{ "Ask when unsure", "Workspace (recommended)", "No sandbox" }; +const claude_explanations = [_][]const u8{ + "The CLI's own default. An unattended loop will wait at the first prompt forever while the graph reports it as running.", + "File edits go through; other tools still ask.", + "Approves the ordinary work of a coding session and keeps its guardrails.", + "Stops asking, without removing the checks themselves.", + "Every permission check is skipped. A loop can do anything you can.", +}; +const copilot_explanations = [_][]const u8{ + "Copilot's own default. An unattended loop will wait at the first prompt.", + "Tools run without confirmation, but URL access and paths beyond the granted directories still prompt.", + "Copilot's --yolo: tools, paths, and URLs all approved — what an unattended loop needs.", +}; +const codex_explanations = [_][]const u8{ + "Codex's own default. An unattended loop will wait at the first prompt.", + "Runs without asking, and may write inside the project it was given.", + "Skips every approval and the sandbox entirely — a loop can do anything you can, anywhere.", +}; + +const backend_id = 6112; +const claude_id = 6120; +const copilot_id = 6128; +const codex_id = 6136; +const model_id = 6144; +const auto_model_id = 6152; +const activity_id = 6160; +const briefing_id = 6168; +const beta_id = 6176; +const save_id = 6184; +const cancel_id = 6192; + +const SettingsDialogState = struct { + allocator: std.mem.Allocator, + backend: usize, + model: usize, + claude: usize, + copilot: usize, + codex: usize, + activity: bool, + briefing: bool, + beta: bool, + auto_model: bool, + accepted: bool = false, + closed: bool = false, + controls: [9]c.HWND = @splat(null), + explanations: [9]c.HWND = @splat(null), +}; + +const settings_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeProductSettings"); +const settings_title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Settings"); +const settings_width: i32 = 620; +const settings_height: i32 = 830; +var settings_active = false; +var settings_state: SettingsDialogState = undefined; + +fn choiceIndex(value: []const u8, choices: []const []const u8) usize { + for (choices, 0..) |choice, index| if (std.mem.eql(u8, value, choice)) return index; + return 0; +} + +fn registerSettingsClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&settingsWindowProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = settings_class.ptr; + klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + klass.hbrBackground = null; + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.SettingsClassRegistrationFailed; +} + +fn settingsWindowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + const safe_hwnd: c.HWND = if (hwnd) |value| windowHandle(@intFromPtr(value)) else null; + if (!settings_active) return c.DefWindowProcW(safe_hwnd, message, wparam, lparam); + switch (message) { + c.WM_NCCREATE => return 1, + c.WM_CREATE => return 0, + c.WM_ERASEBKGND => return 1, + c.WM_PAINT => { + var state: c.PAINTSTRUCT = undefined; + const hdc = c.BeginPaint(safe_hwnd, &state); + paintSettings(hdc); + _ = c.EndPaint(safe_hwnd, &state); + return 0; + }, + c.WM_LBUTTONUP => { + const point = CanvasInput.decodeMouseMessage(lparam); + if (applySettingsClick(point.x, point.y)) _ = c.InvalidateRect(safe_hwnd, null, 0); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + switch (command) { + save_id => { + settings_state.accepted = true; + settings_state.closed = true; + }, + cancel_id => settings_state.closed = true, + backend_id => settings_state.backend = (settings_state.backend + 1) % backend_values.len, + claude_id => settings_state.claude = (settings_state.claude + 1) % claude_values.len, + copilot_id => settings_state.copilot = (settings_state.copilot + 1) % copilot_values.len, + codex_id => settings_state.codex = (settings_state.codex + 1) % codex_values.len, + model_id => settings_state.model = (settings_state.model + 1) % model_values.len, + auto_model_id => settings_state.auto_model = !settings_state.auto_model, + activity_id => settings_state.activity = !settings_state.activity, + briefing_id => settings_state.briefing = !settings_state.briefing, + beta_id => settings_state.beta = !settings_state.beta, + else => return c.DefWindowProcW(safe_hwnd, message, wparam, lparam), + } + updateSettingsControls(); + _ = c.InvalidateRect(safe_hwnd, null, 0); + return 0; + }, + c.WM_CTLCOLORSTATIC => { + const hdc = deviceContext(wparam); + _ = c.SetTextColor(hdc, settingsRgb(190, 190, 198)); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + return @intCast(@intFromPtr(c.GetStockObject(c.NULL_BRUSH))); + }, + c.WM_KEYDOWN => { + if (wparam == c.VK_ESCAPE) { + settings_state.closed = true; + return 0; + } + if (wparam == c.VK_RETURN) { + settings_state.accepted = true; + settings_state.closed = true; + return 0; + } + }, + c.WM_CLOSE => { + settings_state.closed = true; + return 0; + }, + else => {}, + } + return c.DefWindowProcW(safe_hwnd, message, wparam, lparam); +} + +fn applySettingsClick(x: i32, y: i32) bool { + if (inside(x, y, settingsRect(476, 780, 584, 818))) { + settings_state.accepted = true; + settings_state.closed = true; + return false; + } + if (inside(x, y, settingsRect(374, 780, 466, 818))) { + settings_state.closed = true; + return false; + } + if (rowHit(x, y, 70)) settings_state.backend = (settings_state.backend + 1) % backend_values.len + else if (rowHit(x, y, 148)) settings_state.claude = (settings_state.claude + 1) % claude_values.len + else if (rowHit(x, y, 210)) settings_state.copilot = (settings_state.copilot + 1) % copilot_values.len + else if (rowHit(x, y, 280)) settings_state.codex = (settings_state.codex + 1) % codex_values.len + else if (rowHit(x, y, 398)) settings_state.model = (settings_state.model + 1) % model_values.len + else if (rowHit(x, y, 432)) settings_state.auto_model = !settings_state.auto_model + else if (rowHit(x, y, 512)) settings_state.activity = !settings_state.activity + else if (rowHit(x, y, 584)) settings_state.briefing = !settings_state.briefing + else if (rowHit(x, y, 672)) settings_state.beta = !settings_state.beta + else return false; + updateSettingsControls(); + return true; +} + +fn rowHit(x: i32, y: i32, top: i32) bool { + return inside(x, y, settingsRect(36, top, 584, top + 48)); +} + +fn paintSettings(hdc: c.HDC) void { + settingsFill(hdc, settingsRect(0, 0, settings_width, settings_height), settingsRgb(35, 35, 38)); + settingsText(hdc, "Settings", settingsRect(36, 22, 584, 54), 24, settingsRgb(245, 245, 247), true); + settingsText(hdc, "DEFAULT BACKEND", settingsRect(36, 52, 260, 68), 10, settingsRgb(135, 135, 142), true); + settingsText(hdc, "PERMISSIONS", settingsRect(36, 130, 260, 146), 10, settingsRgb(135, 135, 142), true); + settingsText(hdc, "MODEL", settingsRect(36, 380, 260, 396), 10, settingsRgb(135, 135, 142), true); + settingsText(hdc, "BEHAVIOR", settingsRect(36, 494, 260, 510), 10, settingsRgb(135, 135, 142), true); + settingsText(hdc, "UPDATES", settingsRect(36, 654, 260, 670), 10, settingsRgb(135, 135, 142), true); +} + +fn createSettingsControls(hwnd: c.HWND) void { + settings_state.controls[0] = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + std.unicode.utf8ToUtf16LeStringLiteral("New loops use: Claude Code").ptr, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, + 36, + 70, + 548, + 30, + hwnd, + @ptrFromInt(backend_id), + c.GetModuleHandleW(null), + null, + ); + settings_state.explanations[0] = createSettingsControl(hwnd, "STATIC", "Which backend a new loop starts on. You can still change it per loop.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 102, 524, 24, 0); + + settings_state.controls[1] = createSettingsControl(hwnd, "BUTTON", "", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, 36, 148, 548, 30, claude_id); + settings_state.explanations[1] = createSettingsControl(hwnd, "STATIC", "", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 180, 524, 28, 0); + settings_state.controls[2] = createSettingsControl(hwnd, "BUTTON", "", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, 36, 210, 548, 30, copilot_id); + settings_state.explanations[2] = createSettingsControl(hwnd, "STATIC", "", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 242, 524, 36, 0); + settings_state.controls[3] = createSettingsControl(hwnd, "BUTTON", "", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, 36, 280, 548, 30, codex_id); + settings_state.explanations[3] = createSettingsControl(hwnd, "STATIC", "", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 312, 524, 28, 0); + settings_state.explanations[4] = createSettingsControl(hwnd, "STATIC", "A loop runs whether or not this window is open, so nobody is there to answer a permission prompt.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 342, 524, 34, 0); + + settings_state.controls[4] = createSettingsControl(hwnd, "BUTTON", "", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, 36, 398, 548, 30, model_id); + settings_state.controls[5] = createSettingsControl(hwnd, "BUTTON", "Pick a model for each loop", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 44, 432, 532, 26, auto_model_id); + settings_state.explanations[5] = createSettingsControl(hwnd, "STATIC", "The default model tier is copied into new loops. On, an unpinned loop is routed by its type; a per-loop model always wins.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 462, 524, 42, 0); + + settings_state.controls[6] = createSettingsControl(hwnd, "BUTTON", "Show the activity strip", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 44, 512, 532, 26, activity_id); + settings_state.explanations[6] = createSettingsControl(hwnd, "STATIC", "A strip along the window's bottom lists passes, hand-offs, and state changes as they happen. It starts empty after a relaunch.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 542, 524, 38, 0); + settings_state.controls[7] = createSettingsControl(hwnd, "BUTTON", "Tell sessions they're part of a graph", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 44, 584, 532, 26, briefing_id); + settings_state.explanations[7] = createSettingsControl(hwnd, "STATIC", "Lets a loop create more loops when work genuinely splits. Off, a session does its assigned work and never creates anything.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 614, 524, 38, 0); + + settings_state.controls[8] = createSettingsControl(hwnd, "BUTTON", "Get beta releases", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 44, 672, 532, 26, beta_id); + settings_state.explanations[8] = createSettingsControl(hwnd, "STATIC", "On, Check for Updates offers pre-releases as well as stable releases — newer features, less soak time. Off, stable releases only.", c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 48, 702, 524, 50, 0); + + _ = createSettingsControl(hwnd, "BUTTON", "Cancel", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_PUSHBUTTON, 374, 780, 92, 38, cancel_id); + _ = createSettingsControl(hwnd, "BUTTON", "Save", c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_DEFPUSHBUTTON, 476, 780, 108, 38, save_id); + updateSettingsControls(); +} + +fn createSettingsControl(hwnd: c.HWND, class_name: []const u8, text: []const u8, style: c.DWORD, x: i32, y: i32, width: i32, height: i32, id: usize) c.HWND { + const wide_text = settingsWideZ(text) catch return null; + defer settings_state.allocator.free(wide_text); + const menu: c.HMENU = if (id == 0) null else @ptrFromInt(id); + const wide_class = if (std.mem.eql(u8, class_name, "BUTTON")) + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr + else + std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr; + const control = c.CreateWindowExW(0, wide_class, wide_text.ptr, style, x, y, width, height, hwnd, menu, c.GetModuleHandleW(null), null); + return control; +} + +fn updateSettingsControls() void { + setSettingsControlText(settings_state.controls[0], "New loops use: ", backend_labels[settings_state.backend]); + setSettingsControlText(settings_state.controls[1], "Claude Code: ", claude_labels[settings_state.claude]); + setSettingsControlText(settings_state.controls[2], "Copilot CLI: ", copilot_labels[settings_state.copilot]); + setSettingsControlText(settings_state.controls[3], "Codex: ", codex_labels[settings_state.codex]); + setSettingsControlText(settings_state.controls[4], "Default model: ", model_labels[settings_state.model]); + setSettingsControlText(settings_state.explanations[1], "", claude_explanations[settings_state.claude]); + setSettingsControlText(settings_state.explanations[2], "", copilot_explanations[settings_state.copilot]); + setSettingsControlText(settings_state.explanations[3], "", codex_explanations[settings_state.codex]); + setSettingsCheck(settings_state.controls[5], settings_state.auto_model); + setSettingsCheck(settings_state.controls[6], settings_state.activity); + setSettingsCheck(settings_state.controls[7], settings_state.briefing); + setSettingsCheck(settings_state.controls[8], settings_state.beta); +} + +fn setSettingsControlText(hwnd: c.HWND, prefix: []const u8, value: []const u8) void { + if (hwnd == null) return; + const text = std.fmt.allocPrint(settings_state.allocator, "{s}{s}", .{ prefix, value }) catch return; + defer settings_state.allocator.free(text); + const wide = settingsWideZ(text) catch return; + defer settings_state.allocator.free(wide); + _ = c.SetWindowTextW(hwnd, wide.ptr); +} + +fn setSettingsCheck(hwnd: c.HWND, checked: bool) void { + if (hwnd != null) _ = c.SendMessageW(hwnd, c.BM_SETCHECK, if (checked) c.BST_CHECKED else c.BST_UNCHECKED, 0); +} + +fn settingsWideZ(value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(settings_state.allocator, value); + defer settings_state.allocator.free(raw); + const result = try settings_state.allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +fn windowHandle(address: usize) c.HWND { + @setRuntimeSafety(false); + return @ptrFromInt(address); +} + +fn deviceContext(address: usize) c.HDC { + @setRuntimeSafety(false); + return @ptrFromInt(address); +} + +fn settingsText(hdc: c.HDC, value: []const u8, bounds_value: c.RECT, size: i32, color: u32, bold: bool) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(settings_state.allocator, value) catch return; + defer settings_state.allocator.free(wide); + const face = std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI"); + const font = c.CreateFontW(-size, 0, 0, 0, if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, 0, 0, 0, c.DEFAULT_CHARSET, c.OUT_DEFAULT_PRECIS, c.CLIP_DEFAULT_PRECIS, c.CLEARTYPE_QUALITY, c.DEFAULT_PITCH | c.FF_DONTCARE, face.ptr); + const old_font = if (font != null) c.SelectObject(hdc, font) else null; + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = bounds_value; + const alignment: c_int = if (bounds.right - bounds.left < 150) c.DT_CENTER else c.DT_LEFT | c.DT_END_ELLIPSIS; + const format: c.UINT = @intCast(c.DT_SINGLELINE | c.DT_VCENTER | alignment); + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); + if (font != null) { + _ = c.SelectObject(hdc, old_font); + _ = c.DeleteObject(font); + } +} + +fn settingsFill(hdc: c.HDC, bounds: c.RECT, color: u32) void { + const brush = c.CreateSolidBrush(color); + if (brush == null) return; + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); +} + +fn settingsRect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { + return .{ .left = left, .top = top, .right = right, .bottom = bottom }; +} + +fn inside(x: i32, y: i32, bounds: c.RECT) bool { + return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; +} + +fn settingsRgb(red: u8, green: u8, blue: u8) u32 { + return @as(u32, red) | (@as(u32, green) << 8) | (@as(u32, blue) << 16); +} + +test "settings round trip preserves product choices and defaults" { + var settings = try Settings.parse(std.testing.allocator, &.{ + "copilotCLI", "capable", "bypassPermissions", "ask", "workspace", "on", "off", "on", "on", + }); + defer settings.deinit(); + const fields = settings.fields(); + var parsed = try Settings.parse(std.testing.allocator, &fields); + defer parsed.deinit(); + try std.testing.expectEqualStrings("copilotCLI", parsed.default_backend); + try std.testing.expect(parsed.activity); + try std.testing.expect(!parsed.briefing); + try std.testing.expect(parsed.beta); +} + +test "settings save preserves worktree policies and unknown JSON fields" { + const path = "graphcode-settings-preservation-test.json"; + std.fs.cwd().deleteFile(path) catch {}; + defer std.fs.cwd().deleteFile(path) catch {}; + var file = try std.fs.cwd().createFile(path, .{}); + try file.writeAll("{\"worktreePolicies\":{\"repo\":\"ask\"},\"futureFlag\":true,\"defaultBackend\":\"claudeCode\"}"); + file.close(); + var store = Store{ .allocator = std.testing.allocator, .path = try std.testing.allocator.dupe(u8, path) }; + defer store.deinit(); + var settings = try Settings.parse(std.testing.allocator, &.{ "copilotCLI", "capable", "auto", "ask", "workspace", "on", "on", "off", "on" }); + defer settings.deinit(); + try store.save(settings); + const saved = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096); + defer std.testing.allocator.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "worktreePolicies") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "futureFlag") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "copilotCLI") != null); +} + +test "settings load then save preserves unknown fields and worktree policies" { + const path = "graphcode-settings-load-save-preservation-test.json"; + std.fs.cwd().deleteFile(path) catch {}; + defer std.fs.cwd().deleteFile(path) catch {}; + var file = try std.fs.cwd().createFile(path, .{}); + try file.writeAll("{\"worktreePolicies\":{\"repo\":\"ask\"},\"future\":{\"enabled\":true},\"defaultBackend\":\"copilotCLI\",\"defaultModelTier\":\"capable\"}"); + file.close(); + var store = Store{ .allocator = std.testing.allocator, .path = try std.testing.allocator.dupe(u8, path) }; + defer store.deinit(); + var settings = try store.load(); + defer settings.deinit(); + try std.testing.expectEqualStrings("copilotCLI", settings.default_backend); + try std.testing.expectEqualStrings("capable", settings.default_model); + try store.save(settings); + const saved = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 4096); + defer std.testing.allocator.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "worktreePolicies") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"future\"") != null); +} + +test "large worktree policies survive load and save" { + const path = "graphcode-settings-large-policy-test.json"; + std.fs.cwd().deleteFile(path) catch {}; + defer std.fs.cwd().deleteFile(path) catch {}; + var policy = std.array_list.Managed(u8).init(std.testing.allocator); + defer policy.deinit(); + try policy.appendSlice("{\"worktreePolicies\":{\""); + try policy.appendNTimes('x', 900 * 1024); + try policy.appendSlice("\":\"ask\"},\"defaultBackend\":\"claudeCode\"}"); + var file = try std.fs.cwd().createFile(path, .{}); + try file.writeAll(policy.items); + file.close(); + var store = Store{ .allocator = std.testing.allocator, .path = try std.testing.allocator.dupe(u8, path) }; + defer store.deinit(); + var settings = try store.load(); + defer settings.deinit(); + try store.save(settings); + const saved = try std.fs.cwd().readFileAlloc(std.testing.allocator, path, 1024 * 1024); + defer std.testing.allocator.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "worktreePolicies") != null); + try std.testing.expect(saved.len > 900 * 1024); +} + +test "malformed settings surface failure and cannot overwrite without recovery" { + const path = "graphcode-settings-malformed-test.json"; + std.fs.cwd().deleteFile(path) catch {}; + defer std.fs.cwd().deleteFile(path) catch {}; + var file = try std.fs.cwd().createFile(path, .{}); + try file.writeAll("{not-json"); + file.close(); + var store = Store{ .allocator = std.testing.allocator, .path = try std.testing.allocator.dupe(u8, path) }; + defer store.deinit(); + _ = store.load() catch {}; + var settings = try Settings.init(std.testing.allocator); + defer settings.deinit(); + try std.testing.expectError(error.SettingsLoadFailed, store.save(settings)); +} + +test "settings reject values outside Swift selector enums" { + try std.testing.expectError(error.InvalidSettingValue, Settings.parse(std.testing.allocator, &.{ + "not-a-backend", "standard", "auto", "ask", "workspace", "off", "on", "off", "off", + })); + try std.testing.expectError(error.InvalidSettingValue, Settings.parse(std.testing.allocator, &.{ + "claudeCode", "not-a-tier", "auto", "ask", "workspace", "off", "on", "off", "off", + })); +} + +test "purpose-built settings rows cycle choices and toggle behavior" { + settings_state = .{ + .allocator = std.testing.allocator, + .backend = 0, + .model = 0, + .claude = 0, + .copilot = 0, + .codex = 0, + .activity = false, + .briefing = true, + .beta = false, + .auto_model = false, + }; + try std.testing.expect(applySettingsClick(100, 100)); + try std.testing.expectEqual(@as(usize, 1), settings_state.backend); + try std.testing.expect(applySettingsClick(100, 406)); + try std.testing.expectEqual(@as(usize, 1), settings_state.model); + try std.testing.expect(applySettingsClick(100, 462)); + try std.testing.expect(settings_state.auto_model); + try std.testing.expect(applySettingsClick(100, 546)); + try std.testing.expect(settings_state.activity); + try std.testing.expect(applySettingsClick(100, 602)); + try std.testing.expect(!settings_state.briefing); + try std.testing.expect(applySettingsClick(100, 688)); + try std.testing.expect(settings_state.beta); +} + +test "settings expose Swift permission labels and consequence copy" { + try std.testing.expectEqualStrings("Auto (recommended)", claude_labels[2]); + try std.testing.expectEqualStrings("YOLO (recommended)", copilot_labels[2]); + try std.testing.expectEqualStrings("Workspace (recommended)", codex_labels[1]); + try std.testing.expect(std.mem.indexOf(u8, claude_explanations[2], "guardrails") != null); + try std.testing.expect(std.mem.indexOf(u8, copilot_explanations[2], "tools, paths, and URLs") != null); + try std.testing.expect(std.mem.indexOf(u8, codex_explanations[1], "write inside the project") != null); +} diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig new file mode 100644 index 00000000..6834c995 --- /dev/null +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -0,0 +1,1147 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; + +pub const CloneFields = struct { + url: []const u8 = "", + destination: []const u8 = "", + branch: []const u8 = "", + depth: []const u8 = "", + + fn values(self: CloneFields) [4][]const u8 { + return .{ self.url, self.destination, self.branch, self.depth }; + } +}; + +pub const RemoteFields = struct { + host: []const u8 = "", + user: []const u8 = "", + port: []const u8 = "22", + path: []const u8 = "", + + fn values(self: RemoteFields) [4][]const u8 { + return .{ self.host, self.user, self.port, self.path }; + } +}; + +pub const CloneStatus = enum { ready, cloning, cancelled, finished, failed }; +pub const OutputSnapshot = struct { progress_len: usize, stderr_len: usize }; + +pub const CloneProcess = struct { + allocator: std.mem.Allocator, + child: std.process.Child, + args: []const []u8, + destination: []u8, + staging: []u8, + recent_stderr: [4096]u8 = undefined, + recent_stderr_len: usize = 0, + progress: [256]u8 = undefined, + progress_len: usize = 0, + redaction_pending_stdout: [1024 * 1024]u8 = undefined, + redaction_pending_stdout_len: usize = 0, + redaction_pending_stderr: [1024 * 1024]u8 = undefined, + redaction_pending_stderr_len: usize = 0, + output_lock: std.Thread.Mutex = .{}, + finished: bool = false, + cancelled: bool = false, + + pub fn start(allocator: std.mem.Allocator, fields: CloneFields) !CloneProcess { + if (try inspectDestination(fields.destination)) return error.DestinationAlreadyExists; + const staging = try makeStagingPath(allocator, fields.destination); + errdefer allocator.free(staging); + const staged_fields = CloneFields{ .url = fields.url, .destination = staging, .branch = fields.branch, .depth = fields.depth }; + const args = try cloneCommand(allocator, staged_fields); + var child = std.process.Child.init(args, allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + const destination = allocator.dupe(u8, fields.destination) catch |err| { + _ = std.os.windows.kernel32.TerminateProcess(child.id, 1); + _ = child.wait() catch {}; + for (args) |arg| allocator.free(arg); + allocator.free(args); + return err; + }; + + return .{ .allocator = allocator, .child = child, .args = args, .destination = destination, .staging = staging }; + } + + fn terminate(self: *CloneProcess) void { + self.cancelled = true; + if (comptime @import("builtin").os.tag == .windows) { + _ = std.os.windows.kernel32.TerminateProcess(self.child.id, 1); + } + } + + pub fn finish(self: *CloneProcess) !CloneStatus { + if (self.finished) return if (self.cancelled) .cancelled else .finished; + const term = try self.child.wait(); + self.finished = true; + const status: CloneStatus = if (self.cancelled) .cancelled else switch (term) { + .Exited => |code| if (code == 0) .finished else .failed, + else => .failed, + }; + + self.releaseArgs(); + if (status == .finished) { + std.fs.cwd().rename(self.staging, self.destination) catch { + std.fs.cwd().deleteTree(self.staging) catch {}; + return error.DestinationCommitFailed; + }; + } else { + std.fs.cwd().deleteTree(self.staging) catch {}; + } + return status; + } + + pub fn deinit(self: *CloneProcess) void { + if (!self.finished) { + self.terminate(); + _ = self.finish() catch {}; + } + self.allocator.free(self.destination); + self.allocator.free(self.staging); + self.* = undefined; + } + + fn releaseArgs(self: *CloneProcess) void { + if (self.args.len == 0) return; + for (self.args) |arg| self.allocator.free(arg); + self.allocator.free(self.args); + self.args = &.{}; + } + + pub fn snapshot(self: *CloneProcess, progress: []u8, stderr: []u8) OutputSnapshot { + self.output_lock.lock(); + defer self.output_lock.unlock(); + const progress_len = @min(progress.len, self.progress_len); + const stderr_len = @min(stderr.len, self.recent_stderr_len); + @memcpy(progress[0..progress_len], self.progress[0..progress_len]); + @memcpy(stderr[0..stderr_len], self.recent_stderr[0..stderr_len]); + return .{ .progress_len = progress_len, .stderr_len = stderr_len }; + } + + fn recordOutput(self: *CloneProcess, bytes: []const u8, is_stderr: bool, flush: bool) void { + self.output_lock.lock(); + defer self.output_lock.unlock(); + const pending = if (is_stderr) self.redaction_pending_stderr[0..self.redaction_pending_stderr_len] else self.redaction_pending_stdout[0..self.redaction_pending_stdout_len]; + const combined_len = pending.len + bytes.len; + const combined = self.allocator.alloc(u8, combined_len) catch return; + defer self.allocator.free(combined); + @memcpy(combined[0..pending.len], pending); + @memcpy(combined[pending.len..combined_len], bytes); + var safe_end = if (flush) combined_len else combined_len - @min(combined_len, 128); + if (!flush) { + if (findUnterminatedSecret(combined)) |start_pos| safe_end = @min(safe_end, start_pos); + } + const safe = redactSecrets(self.allocator, combined[0..safe_end]) catch return; + defer self.allocator.free(safe); + const target = if (is_stderr) &self.recent_stderr else &self.progress; + const len = if (is_stderr) &self.recent_stderr_len else &self.progress_len; + const capacity = target.len; + const copy_len = @min(capacity, safe.len); + if (copy_len < capacity) { + @memcpy(target[0..copy_len], safe[safe.len - copy_len ..]); + } else { + @memcpy(target[0..capacity], safe[safe.len - capacity ..]); + } + len.* = copy_len; + if (is_stderr) { + const retained = combined[safe_end..combined_len]; + @memcpy(self.redaction_pending_stderr[0..retained.len], retained); + self.redaction_pending_stderr_len = retained.len; + } else { + const retained = combined[safe_end..combined_len]; + @memcpy(self.redaction_pending_stdout[0..retained.len], retained); + self.redaction_pending_stdout_len = retained.len; + } + } +}; + +fn findUnterminatedSecret(input: []const u8) ?usize { + var index: usize = 0; + while (index < input.len) : (index += 1) { + const is_url = std.mem.startsWith(u8, input[index..], "https://"); + const is_token = std.mem.startsWith(u8, input[index..], "token=") or + std.mem.startsWith(u8, input[index..], "access_token="); + if (!is_url and !is_token) continue; + const rest = input[index..]; + const terminators = if (is_token) "& \t\r\n" else " \t\r\n"; + if (std.mem.indexOfAny(u8, rest, terminators) == null) return index; + } + return null; +} + +fn redactSecrets(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + var output = std.array_list.Managed(u8).init(allocator); + var index: usize = 0; + while (index < input.len) { + if (std.mem.startsWith(u8, input[index..], "https://")) { + const rest = input[index + 8 ..]; + if (std.mem.lastIndexOfScalar(u8, rest, '@')) |at| { + const end = std.mem.indexOfAny(u8, rest[0..at], " \t\r\n") == null; + if (end) { + try output.appendSlice("https://@"); + index += 8 + at + 1; + continue; + } + } + } + if (std.mem.startsWith(u8, input[index..], "token=") or + std.mem.startsWith(u8, input[index..], "access_token=")) + { + const equals = std.mem.indexOfScalar(u8, input[index..], '=').?; + try output.appendSlice(input[index .. index + equals + 1]); + index += equals + 1; + try output.appendSlice(""); + while (index < input.len and std.mem.indexOfScalar(u8, "& \t\r\n", input[index]) == null) index += 1; + continue; + } + try output.append(input[index]); + index += 1; + } + return output.toOwnedSlice(); +} + +fn inspectDestination(path: []const u8) !bool { + var dir = std.fs.cwd().openDir(path, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return false, + error.NotDir => return error.DestinationNotDirectory, + else => return err, + }; + dir.close(); + return true; +} + +fn makeStagingPath(allocator: std.mem.Allocator, destination: []const u8) ![]u8 { + const parent = std.fs.path.dirname(destination) orelse "."; + const base = std.fs.path.basename(destination); + var attempt: usize = 0; + while (attempt < 32) : (attempt += 1) { + const candidate = try std.fmt.allocPrint(allocator, "{s}{c}{s}.graphcode-clone-{d}-{d}", .{ + parent, std.fs.path.sep, base, std.time.nanoTimestamp(), attempt, + }); + if (!try inspectDestination(candidate)) return candidate; + allocator.free(candidate); + } + return error.StagingPathUnavailable; +} + +pub const CloneOperation = struct { + allocator: std.mem.Allocator, + process: *CloneProcess, + thread: std.Thread, + done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + status: CloneStatus = .cloning, + cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + stdout_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + stderr_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + + pub fn start(allocator: std.mem.Allocator, fields: CloneFields) !*CloneOperation { + const operation = try allocator.create(CloneOperation); + errdefer allocator.destroy(operation); + const process = try allocator.create(CloneProcess); + errdefer allocator.destroy(process); + process.* = try CloneProcess.start(allocator, fields); + operation.* = .{ .allocator = allocator, .process = process, .thread = undefined }; + operation.thread = std.Thread.spawn(.{}, worker, .{operation}) catch |err| { + process.deinit(); + allocator.destroy(process); + return err; + }; + return operation; + } + + pub fn cancel(self: *CloneOperation) void { + self.cancel_requested.store(true, .release); + } + + pub fn poll(self: *CloneOperation) ?CloneStatus { + if (!self.done.load(.acquire)) return null; + return self.status; + } + + pub fn snapshot(self: *CloneOperation, progress: []u8, stderr: []u8) OutputSnapshot { + return self.process.snapshot(progress, stderr); + } + + pub fn deinit(self: *CloneOperation) void { + if (!self.done.load(.acquire)) self.cancel(); + self.thread.join(); + self.process.deinit(); + self.allocator.destroy(self.process); + self.allocator.destroy(self); + } + + fn worker(self: *CloneOperation) void { + var stdout_thread = std.Thread.spawn(.{}, drainPipe, .{ self.process, &self.process.child.stdout.?, false, &self.stdout_done }) catch { + self.process.terminate(); + _ = self.process.finish() catch {}; + self.status = .failed; + self.done.store(true, .release); + return; + }; + var stderr_thread = std.Thread.spawn(.{}, drainPipe, .{ self.process, &self.process.child.stderr.?, true, &self.stderr_done }) catch { + self.process.terminate(); + stdout_thread.join(); + _ = self.process.finish() catch {}; + self.status = .failed; + self.done.store(true, .release); + return; + }; + while (!self.stdout_done.load(.acquire) or !self.stderr_done.load(.acquire)) { + if (self.cancel_requested.load(.acquire)) self.process.terminate(); + std.Thread.sleep(10 * std.time.ns_per_ms); + } + stdout_thread.join(); + stderr_thread.join(); + self.status = self.process.finish() catch .failed; + self.done.store(true, .release); + } +}; + +pub fn validateClone(fields: CloneFields) !void { + if (std.mem.trim(u8, fields.url, " \t\r\n").len == 0) return error.MissingRepositoryURL; + if (!std.mem.startsWith(u8, fields.url, "https://")) return error.HTTPSRequired; + if (std.mem.trim(u8, fields.destination, " \t\r\n").len == 0) return error.MissingDestination; + if (std.mem.indexOfScalar(u8, fields.destination, 0) != null) return error.InvalidDestination; + if (fields.depth.len != 0 and std.fmt.parseInt(u32, fields.depth, 10) catch 0 == 0) + return error.InvalidDepth; +} + +pub fn validateRemote(fields: RemoteFields) !void { + if (fields.host.len == 0 or fields.user.len == 0 or fields.path.len == 0) + return error.MissingRemoteField; + if (!std.mem.startsWith(u8, fields.path, "/")) return error.AbsolutePathRequired; + const port = std.fmt.parseInt(u16, fields.port, 10) catch return error.InvalidPort; + if (port == 0) return error.InvalidPort; + if (std.mem.startsWith(u8, fields.host, "-") or std.mem.startsWith(u8, fields.user, "-")) + return error.InvalidSSHComponent; + for ([_][]const u8{ fields.host, fields.user, fields.path }) |value| { + if (std.mem.indexOfAny(u8, value, "\x00\r\n") != null) return error.InvalidSSHComponent; + } +} + +pub fn sshDestination(allocator: std.mem.Allocator, fields: RemoteFields) ![]u8 { + try validateRemote(fields); + return std.fmt.allocPrint(allocator, "{s}@{s}", .{ fields.user, fields.host }); +} + +pub fn remoteProjectURI(allocator: std.mem.Allocator, fields: RemoteFields) ![]u8 { + try validateRemote(fields); + var encoded = std.array_list.Managed(u8).init(allocator); + defer encoded.deinit(); + try encoded.appendSlice("ssh://"); + for (fields.user) |byte| try appendURIByte(&encoded, byte, true); + try encoded.append('@'); + if (std.mem.indexOfScalar(u8, fields.host, ':') != null) try encoded.append('['); + for (fields.host) |byte| try appendURIByte(&encoded, byte, false); + if (std.mem.indexOfScalar(u8, fields.host, ':') != null) try encoded.append(']'); + if (!std.mem.eql(u8, fields.port, "22")) { + try encoded.append(':'); + try encoded.appendSlice(fields.port); + } + for (fields.path) |byte| try appendURIByte(&encoded, byte, byte != '/'); + return encoded.toOwnedSlice(); +} + +fn appendURIByte(list: *std.array_list.Managed(u8), byte: u8, encode: bool) !void { + const safe = std.ascii.isAlphanumeric(byte) or std.mem.indexOfScalar(u8, "-._~", byte) != null; + if (safe or (!encode and (byte == '/' or byte == ':'))) return list.append(byte); + const hex = "0123456789ABCDEF"; + try list.append('%'); + try list.append(hex[byte >> 4]); + try list.append(hex[byte & 15]); +} + +pub fn reconnectCommand(allocator: std.mem.Allocator, fields: RemoteFields) ![]u8 { + const destination = try sshDestination(allocator, fields); + defer allocator.free(destination); + const quoted_destination = try shellQuote(allocator, destination); + defer allocator.free(quoted_destination); + const quoted_path = try shellQuote(allocator, fields.path); + defer allocator.free(quoted_path); + return std.fmt.allocPrint( + allocator, + "ssh -o BatchMode=yes -o ConnectTimeout=10 -p {s} {s} -- zmx attach -- {s}", + .{ fields.port, quoted_destination, quoted_path }, + ); +} + +fn shellQuote(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var size: usize = 2; + for (value) |byte| size += if (byte == '\'') 4 else 1; + var result = try allocator.alloc(u8, size); + var index: usize = 0; + result[index] = '\''; + index += 1; + for (value) |byte| { + if (byte == '\'') { + @memcpy(result[index .. index + 4], "'\\''"); + index += 4; + } else { + result[index] = byte; + index += 1; + } + } + result[index] = '\''; + return result; +} + +pub fn sshValidationArgs(allocator: std.mem.Allocator, fields: RemoteFields) ![][]u8 { + try validateRemote(fields); + var args = std.array_list.Managed([]u8).init(allocator); + try args.append(try allocator.dupe(u8, "ssh")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "BatchMode=yes")); + try args.append(try allocator.dupe(u8, "-o")); + try args.append(try allocator.dupe(u8, "ConnectTimeout=10")); + try args.append(try allocator.dupe(u8, "-p")); + try args.append(try allocator.dupe(u8, fields.port)); + const destination = try sshDestination(allocator, fields); + defer allocator.free(destination); + try args.append(try allocator.dupe(u8, destination)); + const quoted_path = try shellQuote(allocator, fields.path); + defer allocator.free(quoted_path); + const remote_command = try std.fmt.allocPrint(allocator, "git -C {s} rev-parse --show-toplevel", .{quoted_path}); + try args.append(remote_command); + return args.toOwnedSlice(); +} + +pub fn validateRemoteConnection(allocator: std.mem.Allocator, fields: RemoteFields) !void { + const args = try sshValidationArgs(allocator, fields); + defer { + for (args) |arg| allocator.free(arg); + allocator.free(args); + } + var child = std.process.Child.init(args, allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + var out_thread = try std.Thread.spawn(.{}, drainPipeDiscard, .{&child.stdout.?}); + var err_thread = try std.Thread.spawn(.{}, drainPipeDiscard, .{&child.stderr.?}); + out_thread.join(); + err_thread.join(); + switch (try child.wait()) { + .Exited => |code| if (code != 0) return error.SSHValidationFailed, + else => return error.SSHValidationFailed, + } +} + +pub fn saveRemoteConfig(allocator: std.mem.Allocator, fields: RemoteFields) !void { + try validateRemote(fields); + const base = std.process.getEnvVarOwned(allocator, "LOCALAPPDATA") catch + try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(base); + const dir = try std.fs.path.join(allocator, &.{ base, "GraphCode" }); + defer allocator.free(dir); + try std.fs.cwd().makePath(dir); + const path = try std.fs.path.join(allocator, &.{ dir, "remote.ini" }); + defer allocator.free(path); + var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer file.close(); + const data = try std.fmt.allocPrint(allocator, "host={s}\nuser={s}\nport={s}\npath={s}\n", .{ + fields.host, fields.user, fields.port, fields.path, + }); + defer allocator.free(data); + try file.writeAll(data); +} + +pub fn cloneCommand(allocator: std.mem.Allocator, fields: CloneFields) ![]const []u8 { + try validateClone(fields); + var args = std.array_list.Managed([]u8).init(allocator); + try args.append(try allocator.dupe(u8, "git")); + try args.append(try allocator.dupe(u8, "clone")); + try args.append(try allocator.dupe(u8, "--progress")); + if (fields.branch.len != 0) { + try args.append(try allocator.dupe(u8, "--branch")); + try args.append(try allocator.dupe(u8, fields.branch)); + } + if (fields.depth.len != 0) { + try args.append(try allocator.dupe(u8, "--depth")); + try args.append(try allocator.dupe(u8, fields.depth)); + } + try args.append(try allocator.dupe(u8, "--")); + try args.append(try allocator.dupe(u8, fields.url)); + try args.append(try allocator.dupe(u8, fields.destination)); + return args.toOwnedSlice(); +} + +pub fn openClone(parent: c.HWND, allocator: std.mem.Allocator, initial: CloneFields) !?CloneFields { + const result = try openRepositoryDialog(parent, allocator, .clone, initial.values()); + const fields = result orelse return null; + return .{ + .url = fields[0], + .destination = fields[1], + .branch = fields[2], + .depth = fields[3], + }; +} + +pub fn openRemote(parent: c.HWND, allocator: std.mem.Allocator, initial: RemoteFields) !?RemoteFields { + const result = try openRepositoryDialog(parent, allocator, .remote, initial.values()); + const fields = result orelse return null; + return .{ + .host = fields[0], + .user = fields[1], + .port = fields[2], + .path = fields[3], + }; +} + +const DialogKind = enum { clone, remote }; +const repository_dialog_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeRepositoryIngressDialog"); +const clone_title = "Clone Repository"; +const clone_intro = "Enter an HTTPS repository URL and choose where GraphCode should create the local repository folder."; +const remote_title = "Add SSH Repository"; +const remote_intro = "Connect to an existing Git repository over SSH. GraphCode validates the connection before saving it."; +const id_url_or_host = 4101; +const id_destination_or_user = 4102; +const id_branch_or_port = 4103; +const id_depth_or_path = 4104; +const id_browse = 4110; +const id_accept = 1; +const id_cancel = 2; +const em_setcuebanner = 0x1501; + +const RepositoryDialogState = struct { + allocator: std.mem.Allocator, + parent: c.HWND, + kind: DialogKind, + initial: [4][]const u8, + edits: [4]c.HWND = [_]c.HWND{null} ** 4, + error_label: c.HWND = null, + destination_hint: c.HWND = null, + accepted_values: [4][]u8 = [_][]u8{&.{}} ** 4, + clone_parent: ?[]u8 = null, + updating_destination: bool = false, + accepted: bool = false, + closed: bool = false, +}; + +var repository_dialog_active = false; +var repository_dialog_state: RepositoryDialogState = undefined; + +fn openRepositoryDialog( + parent: c.HWND, + allocator: std.mem.Allocator, + kind: DialogKind, + initial: [4][]const u8, +) !?[4][]u8 { + if (repository_dialog_active) return error.RepositoryDialogAlreadyOpen; + try registerRepositoryDialogClass(); + repository_dialog_state = .{ + .allocator = allocator, + .parent = parent, + .kind = kind, + .initial = initial, + }; + repository_dialog_active = true; + errdefer repository_dialog_active = false; + + const title = if (kind == .clone) clone_title else remote_title; + const wide_title = try wideZ(allocator, title); + defer allocator.free(wide_title); + const client_width: i32 = 640; + const client_height: i32 = if (kind == .clone) 500 else 478; + const style = c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU; + const ex_style = c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT; + var frame = c.RECT{ .left = 0, .top = 0, .right = client_width, .bottom = client_height }; + _ = c.AdjustWindowRectEx(&frame, style, 0, ex_style); + const width = frame.right - frame.left; + const height = frame.bottom - frame.top; + var owner: c.RECT = undefined; + _ = c.GetWindowRect(parent, &owner); + const x = owner.left + @divTrunc((owner.right - owner.left) - width, 2); + const y = owner.top + @divTrunc((owner.bottom - owner.top) - height, 2); + const hwnd = c.CreateWindowExW( + ex_style, + repository_dialog_class.ptr, + wide_title.ptr, + style, + x, + y, + width, + height, + parent, + null, + c.GetModuleHandleW(null), + null, + ) orelse { + repository_dialog_active = false; + return error.RepositoryDialogCreationFailed; + }; + _ = c.EnableWindow(parent, 0); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + _ = c.SetFocus(repository_dialog_state.edits[0]); + + var message: c.MSG = undefined; + while (!repository_dialog_state.closed) { + const code = c.GetMessageW(&message, null, 0, 0); + if (code <= 0) { + repository_dialog_state.closed = true; + break; + } + if (c.IsDialogMessageW(hwnd, &message) != 0) continue; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + _ = c.DestroyWindow(hwnd); + _ = c.EnableWindow(parent, 1); + _ = c.SetActiveWindow(parent); + if (repository_dialog_state.clone_parent) |path| allocator.free(path); + repository_dialog_active = false; + if (!repository_dialog_state.accepted) return null; + return repository_dialog_state.accepted_values; +} + +fn registerRepositoryDialogClass() !void { + var klass: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + klass.lpfnWndProc = @ptrCast(&repositoryDialogProc); + klass.hInstance = c.GetModuleHandleW(null); + klass.lpszClassName = repository_dialog_class.ptr; + klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + klass.hbrBackground = c.GetSysColorBrush(c.COLOR_WINDOW); + if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) + return error.RepositoryDialogClassRegistrationFailed; +} + +fn repositoryDialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { + if (!repository_dialog_active) return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_CREATE => { + createRepositoryDialogControls(hwnd); + return 0; + }, + c.WM_COMMAND => { + const command: u16 = @truncate(wparam); + const notification: u16 = @truncate(wparam >> 16); + if (command == id_accept) { + acceptRepositoryDialog(); + return 0; + } + if (command == id_cancel) { + repository_dialog_state.closed = true; + return 0; + } + if (command == id_browse) { + browseCloneDestination(hwnd); + return 0; + } + if (repository_dialog_state.kind == .clone and notification == c.EN_CHANGE) { + if (command == id_url_or_host) updateCloneDestinationPresentation(); + if (command == id_destination_or_user and !repository_dialog_state.updating_destination) { + if (repository_dialog_state.clone_parent) |path| { + repository_dialog_state.allocator.free(path); + repository_dialog_state.clone_parent = null; + } + } + } + }, + c.WM_CLOSE => { + repository_dialog_state.closed = true; + return 0; + }, + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn createRepositoryDialogControls(hwnd: c.HWND) void { + const state = &repository_dialog_state; + const intro = if (state.kind == .clone) clone_intro else remote_intro; + _ = createStatic(hwnd, intro, 24, 20, 592, 42); + if (state.kind == .clone) { + createLabeledEdit(hwnd, 0, "Repository URL", "https://github.com/owner/repository.git", 78); + createLabeledEdit(hwnd, 1, "Destination folder", "C:\\Users\\you\\Source\\repository", 154); + _ = createButton(hwnd, "Browse…", id_browse, 508, 180, 108, 28, false); + state.destination_hint = createStatic(hwnd, "", 24, 214, 592, 20); + createLabeledEdit(hwnd, 2, "Branch (optional)", "Leave empty to use the repository default", 246); + createLabeledEdit(hwnd, 3, "Depth (optional)", "Leave empty for full history", 322); + } else { + createLabeledEdit(hwnd, 0, "Host", "git.example.com", 78); + createLabeledEdit(hwnd, 1, "User", "git", 154); + createLabeledEdit(hwnd, 2, "Port", "22", 230); + createLabeledEdit(hwnd, 3, "Absolute repository path", "/srv/git/repository.git", 306); + } + const button_y: i32 = if (state.kind == .clone) 446 else 424; + state.error_label = createStatic(hwnd, "", 24, button_y - 40, 392, 34); + _ = createButton(hwnd, "Cancel", id_cancel, 430, button_y, 88, 30, false); + _ = createButton(hwnd, if (state.kind == .clone) "Clone" else "Connect", id_accept, 528, button_y, 88, 30, true); + updateCloneDestinationPresentation(); +} + +fn createLabeledEdit(hwnd: c.HWND, index: usize, label: []const u8, cue: []const u8, y: i32) void { + _ = createStatic(hwnd, label, 24, y, 592, 20); + const width: i32 = if (repository_dialog_state.kind == .clone and index == 1) 472 else 592; + const edit = createControl( + hwnd, + c.WS_EX_CLIENTEDGE, + "EDIT", + repository_dialog_state.initial[index], + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL, + 24, + y + 24, + width, + 28, + id_url_or_host + index, + ); + repository_dialog_state.edits[index] = edit; + const wide_cue = wideZ(repository_dialog_state.allocator, cue) catch return; + defer repository_dialog_state.allocator.free(wide_cue); + _ = c.SendMessageW(edit, em_setcuebanner, 1, @bitCast(@intFromPtr(wide_cue.ptr))); +} + +fn createStatic(hwnd: c.HWND, text: []const u8, x: i32, y: i32, width: i32, height: i32) c.HWND { + return createControl(hwnd, 0, "STATIC", text, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, x, y, width, height, 0); +} + +fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, width: i32, height: i32, default: bool) c.HWND { + return createControl( + hwnd, + 0, + "BUTTON", + text, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | @as(c.LONG, if (default) 1 else 0), + x, + y, + width, + height, + id, + ); +} + +fn createControl( + hwnd: c.HWND, + ex_style: c.DWORD, + class: []const u8, + text: []const u8, + style: c.LONG, + x: i32, + y: i32, + width: i32, + height: i32, + id: usize, +) c.HWND { + const allocator = repository_dialog_state.allocator; + const wide_class = wideZ(allocator, class) catch return null; + defer allocator.free(wide_class); + const wide_text = wideZ(allocator, text) catch return null; + defer allocator.free(wide_text); + const control = c.CreateWindowExW( + ex_style, + wide_class.ptr, + wide_text.ptr, + @bitCast(style), + x, + y, + width, + height, + hwnd, + controlId(id), + c.GetModuleHandleW(null), + null, + ) orelse return null; + if (c.GetStockObject(c.DEFAULT_GUI_FONT)) |font| + _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(font), 1); + return control; +} + +fn controlId(id: usize) c.HMENU { + if (id == 0) return null; + @setRuntimeSafety(false); + return @ptrFromInt(id); +} + +fn acceptRepositoryDialog() void { + const allocator = repository_dialog_state.allocator; + const values = readRepositoryDialogValues(allocator) catch { + showRepositoryDialogError("Unable to read the dialog fields."); + return; + }; + var owned_values = values; + if (repository_dialog_state.kind == .clone) { + validateClone(.{ + .url = owned_values[0], + .destination = owned_values[1], + .branch = owned_values[2], + .depth = owned_values[3], + }) catch |err| { + showRepositoryDialogError(validationMessage(err)); + freeDialogValues(allocator, &owned_values); + return; + }; + const destination_exists = inspectDestination(owned_values[1]) catch { + showRepositoryDialogError("The destination folder cannot be used."); + freeDialogValues(allocator, &owned_values); + return; + }; + if (destination_exists) { + showRepositoryDialogError("Choose a destination folder that does not already exist."); + freeDialogValues(allocator, &owned_values); + return; + } + const parent_path = std.fs.path.dirname(owned_values[1]) orelse "."; + var parent_dir = std.fs.cwd().openDir(parent_path, .{}) catch { + showRepositoryDialogError("The destination's parent folder does not exist."); + freeDialogValues(allocator, &owned_values); + return; + }; + parent_dir.close(); + } else { + validateRemote(.{ + .host = owned_values[0], + .user = owned_values[1], + .port = owned_values[2], + .path = owned_values[3], + }) catch |err| { + showRepositoryDialogError(validationMessage(err)); + freeDialogValues(allocator, &owned_values); + return; + }; + } + repository_dialog_state.accepted_values = owned_values; + repository_dialog_state.accepted = true; + repository_dialog_state.closed = true; +} + +fn readRepositoryDialogValues(allocator: std.mem.Allocator) ![4][]u8 { + var values: [4][]u8 = undefined; + var count: usize = 0; + errdefer for (values[0..count]) |value| allocator.free(value); + for (repository_dialog_state.edits, 0..) |edit, index| { + const raw = try readControlText(allocator, edit); + defer allocator.free(raw); + values[index] = try allocator.dupe(u8, std.mem.trim(u8, raw, " \t\r\n")); + count += 1; + } + return values; +} + +fn freeDialogValues(allocator: std.mem.Allocator, values: *[4][]u8) void { + for (values) |value| allocator.free(value); +} + +fn showRepositoryDialogError(message: []const u8) void { + setControlText(repository_dialog_state.error_label, message); + _ = c.ShowWindow(repository_dialog_state.error_label, c.SW_SHOW); +} + +fn validationMessage(err: anyerror) []const u8 { + return switch (err) { + error.MissingRepositoryURL => "Enter the HTTPS repository URL.", + error.HTTPSRequired => "Repository URL must begin with https://.", + error.MissingDestination => "Choose or enter a destination folder.", + error.InvalidDestination => "The destination folder contains invalid characters.", + error.InvalidDepth => "Depth must be a whole number greater than zero.", + error.MissingRemoteField => "Enter the host, user, port, and repository path.", + error.AbsolutePathRequired => "Repository path must be absolute and begin with /.", + error.InvalidPort => "Port must be a number from 1 through 65535.", + error.InvalidSSHComponent => "Host, user, or path contains an invalid value.", + else => "Check the highlighted repository details and try again.", + }; +} + +fn browseCloneDestination(hwnd: c.HWND) void { + var wide_path: [32768]u16 = [_]u16{0} ** 32768; + const picked = graphcode_pick_folder(hwnd, &wide_path, wide_path.len); + if (picked <= 0) { + if (picked < 0) showRepositoryDialogError("The Windows folder picker could not be opened."); + return; + } + const end = std.mem.indexOfScalar(u16, &wide_path, 0) orelse wide_path.len; + const parent = std.unicode.utf16LeToUtf8Alloc(repository_dialog_state.allocator, wide_path[0..end]) catch { + showRepositoryDialogError("The selected folder name could not be read."); + return; + }; + if (repository_dialog_state.clone_parent) |old| repository_dialog_state.allocator.free(old); + repository_dialog_state.clone_parent = parent; + updateCloneDestinationPresentation(); +} + +fn updateCloneDestinationPresentation() void { + if (repository_dialog_state.kind != .clone or repository_dialog_state.edits[0] == null) return; + const allocator = repository_dialog_state.allocator; + const url = readControlText(allocator, repository_dialog_state.edits[0]) catch return; + defer allocator.free(url); + const folder = deriveRepositoryFolderName(allocator, url) catch return; + defer allocator.free(folder); + const hint = std.fmt.allocPrint(allocator, "Repository folder: {s}", .{folder}) catch return; + defer allocator.free(hint); + setControlText(repository_dialog_state.destination_hint, hint); + if (repository_dialog_state.clone_parent) |parent| { + const destination = std.fs.path.join(allocator, &.{ parent, folder }) catch return; + defer allocator.free(destination); + repository_dialog_state.updating_destination = true; + setControlText(repository_dialog_state.edits[1], destination); + repository_dialog_state.updating_destination = false; + } +} + +fn deriveRepositoryFolderName(allocator: std.mem.Allocator, url: []const u8) ![]u8 { + const trimmed = std.mem.trim(u8, url, " \t\r\n"); + const suffix_end = std.mem.indexOfAny(u8, trimmed, "?#") orelse trimmed.len; + const without_suffix = std.mem.trimRight(u8, trimmed[0..suffix_end], "/"); + const scheme = std.mem.indexOf(u8, without_suffix, "://") orelse return allocator.dupe(u8, "repository"); + const slash = std.mem.lastIndexOfScalar(u8, without_suffix, '/') orelse return allocator.dupe(u8, "repository"); + if (slash < scheme + 3) return allocator.dupe(u8, "repository"); + var segment = without_suffix[slash + 1 ..]; + if (std.mem.endsWith(u8, segment, ".git")) segment = segment[0 .. segment.len - 4]; + var result = std.array_list.Managed(u8).init(allocator); + defer result.deinit(); + for (segment) |byte| { + if (byte < 32 or std.mem.indexOfScalar(u8, "<>:\"/\\|?*", byte) != null) + try result.append('-') + else + try result.append(byte); + } + while (result.items.len != 0 and + (result.items[result.items.len - 1] == '.' or result.items[result.items.len - 1] == ' ')) + { + _ = result.pop(); + } + if (result.items.len == 0) return allocator.dupe(u8, "repository"); + return result.toOwnedSlice(); +} + +fn readControlText(allocator: std.mem.Allocator, control: c.HWND) ![]u8 { + const length: usize = @intCast(c.GetWindowTextLengthW(control)); + const wide = try allocator.alloc(u16, length + 1); + defer allocator.free(wide); + const copied = c.GetWindowTextW(control, wide.ptr, @intCast(wide.len)); + return std.unicode.utf16LeToUtf8Alloc(allocator, wide[0..@intCast(copied)]); +} + +fn setControlText(control: c.HWND, value: []const u8) void { + if (control == null) return; + const wide = wideZ(repository_dialog_state.allocator, value) catch return; + defer repository_dialog_state.allocator.free(wide); + _ = c.SetWindowTextW(control, wide.ptr); +} + +fn wideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { + const raw = try std.unicode.utf8ToUtf16LeAlloc(allocator, value); + defer allocator.free(raw); + const result = try allocator.alloc(u16, raw.len + 1); + @memcpy(result[0..raw.len], raw); + result[raw.len] = 0; + return result; +} + +pub fn runClone(allocator: std.mem.Allocator, fields: CloneFields) !CloneStatus { + var process = try CloneProcess.start(allocator, fields); + defer process.deinit(); + var stdout_done = std.atomic.Value(bool).init(false); + var stderr_done = std.atomic.Value(bool).init(false); + var stdout_thread = try std.Thread.spawn(.{}, drainPipe, .{ &process, &process.child.stdout.?, false, &stdout_done }); + var stderr_thread = try std.Thread.spawn(.{}, drainPipe, .{ &process, &process.child.stderr.?, true, &stderr_done }); + stdout_thread.join(); + stderr_thread.join(); + return process.finish(); +} + +fn drainPipe(process: *CloneProcess, file: *std.fs.File, is_stderr: bool, done: *std.atomic.Value(bool)) void { + defer done.store(true, .release); + var buffer: [4096]u8 = undefined; + while (true) { + const count = file.read(&buffer) catch return; + if (count == 0) { + process.recordOutput(&.{}, is_stderr, true); + return; + } + process.recordOutput(buffer[0..count], is_stderr, false); + } +} + +fn drainPipeDiscard(file: *std.fs.File) void { + var buffer: [4096]u8 = undefined; + while ((file.read(&buffer) catch 0) != 0) {} +} + +test "clone dialog derives safe destination folder names" { + const standard = try deriveRepositoryFolderName(std.testing.allocator, "https://example.test/org/GraphCode.git"); + defer std.testing.allocator.free(standard); + try std.testing.expectEqualStrings("GraphCode", standard); + + const sanitized = try deriveRepositoryFolderName(std.testing.allocator, "https://example.test/org/repo%20name.git?ref=main"); + defer std.testing.allocator.free(sanitized); + try std.testing.expectEqualStrings("repo%20name", sanitized); + + const fallback = try deriveRepositoryFolderName(std.testing.allocator, "https://example.test/"); + defer std.testing.allocator.free(fallback); + try std.testing.expectEqualStrings("repository", fallback); +} + +test "repository dialogs expose purpose-built copy and actionable validation" { + try std.testing.expect(std.mem.indexOf(u8, clone_intro, "HTTPS repository URL") != null); + try std.testing.expect(std.mem.indexOf(u8, remote_intro, "validates the connection") != null); + try std.testing.expectEqualStrings( + "Depth must be a whole number greater than zero.", + validationMessage(error.InvalidDepth), + ); + try std.testing.expectEqualStrings( + "Repository path must be absolute and begin with /.", + validationMessage(error.AbsolutePathRequired), + ); +} + +test "clone validation and argv preserve HTTPS, Unicode, and option boundaries" { + const fields = CloneFields{ + .url = "https://example.test/repo.git", + .destination = "C:\\Users\\dev\\Проекты\\repo", + .branch = "main", + .depth = "1", + }; + const args = try cloneCommand(std.testing.allocator, fields); + defer { + for (args) |arg| std.testing.allocator.free(arg); + std.testing.allocator.free(args); + } + try std.testing.expectEqualStrings("--", args[7]); + try std.testing.expectEqualStrings(fields.destination, args[9]); +} + +test "SSH validation and reconnect command reject injection-shaped identities" { + try std.testing.expectError(error.InvalidSSHComponent, validateRemote(.{ .host = "-oProxyCommand=x", .user = "dev", .path = "/repo" })); + const command = try reconnectCommand(std.testing.allocator, .{ .host = "build-box", .user = "dev", .path = "/srv/граф" }); + defer std.testing.allocator.free(command); + try std.testing.expect(std.mem.indexOf(u8, command, "BatchMode=yes") != null); + try std.testing.expect(std.mem.indexOf(u8, command, "/srv/граф") != null); +} + +test "SSH validation argv uses one quoted remote command" { + const args = try sshValidationArgs(std.testing.allocator, .{ + .host = "build-box", + .user = "dev", + .port = "2222", + .path = "/srv/граф", + }); + defer { + for (args) |arg| std.testing.allocator.free(arg); + std.testing.allocator.free(args); + } + try std.testing.expectEqualStrings("BatchMode=yes", args[2]); + try std.testing.expectEqualStrings("dev@build-box", args[7]); + try std.testing.expectEqualStrings("git -C '/srv/граф' rev-parse --show-toplevel", args[8]); +} + +test "SSH reconnect command quotes shell metacharacters and rejects newlines" { + const command = try reconnectCommand(std.testing.allocator, .{ + .host = "build-box", + .user = "dev", + .path = "/srv/a;$(touch p)'q", + }); + defer std.testing.allocator.free(command); + try std.testing.expect(std.mem.indexOf(u8, command, "'/srv/a;$(touch p)'\\''q'") != null); + try std.testing.expectError(error.InvalidSSHComponent, validateRemote(.{ + .host = "build-box", + .user = "dev\nwhoami", + .path = "/repo", + })); +} + +test "remote URI percent-encodes path and brackets IPv6" { + const uri = try remoteProjectURI(std.testing.allocator, .{ + .host = "2001:db8::1", + .user = "dev", + .port = "2200", + .path = "/repo name/#q?x%雪", + }); + defer std.testing.allocator.free(uri); + try std.testing.expectEqualStrings("ssh://dev@[2001:db8::1]:2200/repo%20name/%23q%3Fx%25%E9%9B%AA", uri); +} + +test "redaction handles output larger than four kilobytes" { + var input = std.array_list.Managed(u8).init(std.testing.allocator); + defer input.deinit(); + try input.appendNTimes('x', 8192); + try input.appendSlice(" https://user:very-long-secret@example.test/repo.git "); + const safe = try redactSecrets(std.testing.allocator, input.items); + defer std.testing.allocator.free(safe); + try std.testing.expect(std.mem.indexOf(u8, safe, "very-long-secret") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "@example.test") != null); +} + +test "redaction holds and removes an eight kilobyte split credential" { + var secret = std.array_list.Managed(u8).init(std.testing.allocator); + defer secret.deinit(); + try secret.appendNTimes('s', 9000); + var process = CloneProcess{ + .allocator = std.testing.allocator, + .child = undefined, + .args = &.{}, + .destination = &.{}, + .staging = &.{}, + }; + var first = std.array_list.Managed(u8).init(std.testing.allocator); + defer first.deinit(); + try first.appendSlice("fatal: https://user:"); + try first.appendSlice(secret.items); + process.recordOutput(first.items, true, false); + var second = std.array_list.Managed(u8).init(std.testing.allocator); + defer second.deinit(); + try second.appendSlice("@example.test/repo.git"); + process.recordOutput(second.items, true, true); + var progress: [256]u8 = undefined; + var stderr: [4096]u8 = undefined; + const snapshot = process.snapshot(&progress, &stderr); + try std.testing.expect(std.mem.indexOf(u8, stderr[0..snapshot.stderr_len], secret.items) == null); + try std.testing.expect(std.mem.indexOf(u8, stderr[0..snapshot.stderr_len], "@example.test") != null); +} + +test "clone output redacts hostile HTTPS credentials" { + const safe = try redactSecrets(std.testing.allocator, "fatal: https://user:p@ss;token@example.test/repo.git?access_token=secret"); + defer std.testing.allocator.free(safe); + try std.testing.expect(std.mem.indexOf(u8, safe, "user:p@ss") == null); + try std.testing.expect(std.mem.indexOf(u8, safe, "@example.test") != null); + try std.testing.expect(std.mem.indexOf(u8, safe, "secret") == null); +} + +test "clone redaction survives split credential boundaries" { + var process = CloneProcess{ + .allocator = std.testing.allocator, + .child = undefined, + .args = &.{}, + .destination = &.{}, + .staging = &.{}, + }; + process.recordOutput("fatal https://user:secret@", true, false); + process.recordOutput("example.test/repo.git", true, true); + var progress: [256]u8 = undefined; + var stderr: [256]u8 = undefined; + const snapshot = process.snapshot(&progress, &stderr); + try std.testing.expect(std.mem.indexOf(u8, stderr[0..snapshot.stderr_len], "secret") == null); + try std.testing.expect(std.mem.indexOf(u8, stderr[0..snapshot.stderr_len], "@example.test") != null); +} + +test "clone cancellation only signals worker ownership" { + var operation = CloneOperation{ + .allocator = std.testing.allocator, + .process = undefined, + .thread = undefined, + }; + operation.cancel(); + try std.testing.expect(operation.cancel_requested.load(.acquire)); +} + +test "clone refuses non-empty destinations without deleting sentinels" { + const path = "graphcode-clone-sentinel-regression"; + std.fs.cwd().deleteTree(path) catch {}; + try std.fs.cwd().makePath(path); + defer std.fs.cwd().deleteTree(path) catch {}; + var sentinel = try std.fs.cwd().createFile("graphcode-clone-sentinel-regression\\keep.txt", .{}); + try sentinel.writeAll("keep"); + sentinel.close(); + try std.testing.expectError(error.DestinationAlreadyExists, CloneProcess.start(std.testing.allocator, .{ + .url = "https://example.test/repo.git", + .destination = path, + })); + var kept = try std.fs.cwd().openFile("graphcode-clone-sentinel-regression\\keep.txt", .{}); + defer kept.close(); + var bytes: [4]u8 = undefined; + var reader = kept.reader(&.{}); + try reader.interface.readSliceAll(&bytes); + try std.testing.expectEqualStrings("keep", &bytes); +} diff --git a/graphcode-windows/src/WindowsUpdates.zig b/graphcode-windows/src/WindowsUpdates.zig new file mode 100644 index 00000000..b907eee0 --- /dev/null +++ b/graphcode-windows/src/WindowsUpdates.zig @@ -0,0 +1,399 @@ +const std = @import("std"); +const c = @import("Win32.zig").c; + +pub const Channel = enum { stable, beta }; +pub const State = enum { disabled, available, up_to_date, failed }; + +pub fn acceptsResult(current_generation: u64, result_generation: u64, cancelled: bool) bool { + return !cancelled and current_generation == result_generation; +} + +pub const CheckResult = struct { + channel: Channel, + state: State, + version: ?[]u8 = null, + release_url: ?[]u8 = null, + message: ?[]u8 = null, + + pub fn deinit(self: *CheckResult, allocator: std.mem.Allocator) void { + if (self.version) |value| allocator.free(value); + if (self.release_url) |value| allocator.free(value); + if (self.message) |value| allocator.free(value); + self.* = undefined; + } +}; + +pub const CheckState = struct { + channel: Channel = .stable, + state: State = .disabled, + + pub fn configure(beta_enabled: bool) CheckState { + return .{ .channel = if (beta_enabled) .beta else .stable, .state = .up_to_date }; + } + + pub fn label(self: CheckState) []const u8 { + return switch (self.state) { + .disabled => "Updates disabled", + .available => if (self.channel == .beta) "Beta update available" else "Stable update available", + .up_to_date => if (self.channel == .beta) "Beta updates up to date" else "Stable updates up to date", + .failed => "Update check failed", + }; + } +}; + +pub const CheckClient = struct { + allocator: std.mem.Allocator, + feed_url: []const u8 = "https://api.github.com/repos/GraphCode/GraphCode/releases", + + pub fn check(self: CheckClient, beta_enabled: bool, current_version: []const u8) !CheckResult { + var cancelled = std.atomic.Value(bool).init(false); + return self.checkWithCancel(beta_enabled, current_version, &cancelled); + } + + pub fn checkWithCancel( + self: CheckClient, + beta_enabled: bool, + current_version: []const u8, + cancelled: *std.atomic.Value(bool), + ) !CheckResult { + const channel: Channel = if (beta_enabled) .beta else .stable; + const body = try fetchWinHttp(self.allocator, self.feed_url, cancelled); + defer self.allocator.free(body); + return parseFeed(self.allocator, body, channel, current_version); + } +}; + +fn fetchWinHttp(allocator: std.mem.Allocator, feed_url: []const u8, cancelled: *std.atomic.Value(bool)) ![]u8 { + if (cancelled.load(.acquire)) return error.Cancelled; + const uri = try std.Uri.parse(feed_url); + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const scratch = arena.allocator(); + const host_component = uri.host orelse return error.InvalidUpdateFeed; + const host = try host_component.toRawMaybeAlloc(scratch); + const path_component = try uri.path.toRawMaybeAlloc(scratch); + const query = if (uri.query) |value| try value.toRawMaybeAlloc(scratch) else null; + const path = if (query) |value| try std.fmt.allocPrint(scratch, "{s}?{s}", .{ path_component, value }) else path_component; + const host16 = try utf16Z(scratch, host); + const path16 = try utf16Z(scratch, path); + const agent16 = try utf16Z(scratch, "GraphCode-Windows-Updater"); + const accept_header16 = try utf16Z(scratch, "Accept: application/vnd.github+json"); + const user_agent_header16 = try utf16Z(scratch, "User-Agent: GraphCode-Windows-Updater"); + const session = c.WinHttpOpen(agent16.ptr, c.WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, null, null, 0) orelse return error.UpdateConnectFailed; + defer _ = c.WinHttpCloseHandle(session); + if (c.WinHttpSetTimeouts(session, 2000, 2000, 2000, 2000) == 0) return error.UpdateConnectFailed; + const port: c.INTERNET_PORT = uri.port orelse if (std.mem.eql(u8, uri.scheme, "https")) 443 else 80; + const connection = c.WinHttpConnect(session, host16.ptr, port, 0) orelse return error.UpdateConnectFailed; + defer _ = c.WinHttpCloseHandle(connection); + const flags: c.DWORD = if (std.mem.eql(u8, uri.scheme, "https")) c.WINHTTP_FLAG_SECURE else 0; + const verb: [*:0]const u16 = &[_:0]u16{ 'G', 'E', 'T' }; + var accepts = [_]?[*:0]const u16{null}; + const request = c.WinHttpOpenRequest(connection, verb, path16.ptr, null, null, @ptrCast(&accepts), flags) orelse return error.UpdateConnectFailed; + defer _ = c.WinHttpCloseHandle(request); + if (c.WinHttpAddRequestHeaders(request, accept_header16.ptr, @intCast(accept_header16.len), c.WINHTTP_ADDREQ_FLAG_ADD) == 0 or + c.WinHttpAddRequestHeaders(request, user_agent_header16.ptr, @intCast(user_agent_header16.len), c.WINHTTP_ADDREQ_FLAG_ADD) == 0) + return error.UpdateSendFailed; + if (cancelled.load(.acquire)) return error.Cancelled; + if (c.WinHttpSendRequest(request, @as([*c]const u16, null), 0, null, 0, 0, 0) == 0) return error.UpdateSendFailed; + if (cancelled.load(.acquire)) return error.Cancelled; + if (c.WinHttpReceiveResponse(request, null) == 0) return error.UpdateReceiveFailed; + var status: c.DWORD = 0; + var status_len: c.DWORD = @sizeOf(c.DWORD); + if (c.WinHttpQueryHeaders(request, c.WINHTTP_QUERY_STATUS_CODE | c.WINHTTP_QUERY_FLAG_NUMBER, null, &status, &status_len, null) == 0 or status != 200) + return error.UpdateFeedUnavailable; + var body = std.array_list.Managed(u8).init(allocator); + defer body.deinit(); + while (true) { + if (cancelled.load(.acquire)) return error.Cancelled; + var available: c.DWORD = 0; + if (c.WinHttpQueryDataAvailable(request, &available) == 0) return error.UpdateReceiveFailed; + if (available == 0) break; + if (body.items.len + available > 1024 * 1024) return error.UpdateFeedTooLarge; + const old_len = body.items.len; + try body.resize(old_len + available); + var read: c.DWORD = 0; + if (c.WinHttpReadData(request, body.items[old_len..].ptr, available, &read) == 0) return error.UpdateReceiveFailed; + body.items.len = old_len + read; + } + + return body.toOwnedSlice(); +} + +fn utf16Z(allocator: std.mem.Allocator, value: []const u8) ![:0]u16 { + return std.unicode.utf8ToUtf16LeAllocZ(allocator, value); +} + +pub fn currentVersion(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_VERSION")) |value| { + if (value.len != 0) return value; + allocator.free(value); + } else |_| {} + return currentVersionFromMetadata(allocator, null); +} + +pub fn currentVersionFromMetadata(allocator: std.mem.Allocator, metadata: ?[]const u8) ![]u8 { + if (metadata) |value| if (value.len != 0) return allocator.dupe(u8, value); + return allocator.dupe(u8, "dev"); +} + +fn parseFeed(allocator: std.mem.Allocator, body: []const u8, channel: Channel, current_version: []const u8) !CheckResult { + var parsed = try std.json.parseFromSlice([]const Release, allocator, body, .{}); + defer parsed.deinit(); + var installed = try SemVer.parse(allocator, current_version); + defer installed.deinit(allocator); + var greatest: ?struct { release: Release, version: SemVer } = null; + for (parsed.value) |release| { + if (release.draft or (channel == .stable and release.prerelease)) continue; + const candidate = SemVer.parse(allocator, release.tag_name) catch continue; + if (greatest == null or candidate.compare(greatest.?.version) == .greater) { + if (greatest) |old| old.version.deinit(allocator); + greatest = .{ .release = release, .version = candidate }; + } else { + candidate.deinit(allocator); + } + } + if (greatest) |selected| { + defer selected.version.deinit(allocator); + return .{ + .channel = channel, + .state = if (selected.version.compare(installed) == .greater) .available else .up_to_date, + .version = try allocator.dupe(u8, selected.release.tag_name), + .release_url = if (selected.release.html_url) |url| try allocator.dupe(u8, url) else null, + }; + } + return .{ .channel = channel, .state = .failed, .message = try allocator.dupe(u8, "No release found for selected channel") }; +} + +const SemVer = struct { + core: []u64, + prerelease: ?[]const u8 = null, + + const Order = enum { less, equal, greater }; + + fn parse(allocator: std.mem.Allocator, input: []const u8) !SemVer { + var value = input; + if (value.len > 0 and (value[0] == 'v' or value[0] == 'V')) value = value[1..]; + const build_start = std.mem.indexOfScalar(u8, value, '+') orelse value.len; + value = value[0..build_start]; + const pre_start = std.mem.indexOfScalar(u8, value, '-') orelse value.len; + const core = value[0..pre_start]; + var core_values = std.array_list.Managed(u64).init(allocator); + defer core_values.deinit(); + var numbers = std.mem.splitScalar(u8, core, '.'); + while (numbers.next()) |number| try core_values.append(try parseNumber(number)); + if (core_values.items.len == 0) return error.InvalidVersion; + const prerelease = if (pre_start < value.len) value[pre_start + 1 ..] else null; + if (prerelease) |identifiers| { + if (identifiers.len == 0) return error.InvalidVersion; + var parts = std.mem.splitScalar(u8, identifiers, '.'); + while (parts.next()) |part| { + if (part.len == 0) return error.InvalidVersion; + if (isNumeric(part) and part.len > 1 and part[0] == '0') return error.InvalidVersion; + } + } + return .{ .core = try core_values.toOwnedSlice(), .prerelease = prerelease }; + } + + fn deinit(self: *const SemVer, allocator: std.mem.Allocator) void { + allocator.free(self.core); + } + + fn compare(self: SemVer, other: SemVer) Order { + const core_len = @max(self.core.len, other.core.len); + for (0..core_len) |index| { + const left = if (index < self.core.len) self.core[index] else 0; + const right = if (index < other.core.len) other.core[index] else 0; + if (left != right) return if (left < right) .less else .greater; + } + if (self.prerelease == null and other.prerelease == null) return .equal; + if (self.prerelease == null) return .greater; + if (other.prerelease == null) return .less; + var left = std.mem.splitScalar(u8, self.prerelease.?, '.'); + var right = std.mem.splitScalar(u8, other.prerelease.?, '.'); + while (true) { + const left_part = left.next(); + const right_part = right.next(); + if (left_part == null and right_part == null) return .equal; + if (left_part == null) return .less; + if (right_part == null) return .greater; + const l = left_part.?; + const r = right_part.?; + if (isNumeric(l) and isNumeric(r)) { + const ln = std.fmt.parseInt(u64, l, 10) catch return .less; + const rn = std.fmt.parseInt(u64, r, 10) catch return .greater; + if (ln != rn) return if (ln < rn) .less else .greater; + } else if (isNumeric(l) != isNumeric(r)) { + return if (isNumeric(l)) .less else .greater; + } else if (!std.mem.eql(u8, l, r)) { + if (compareBetaIdentifiers(l, r)) |order| { + if (order != .equal) return order; + continue; + } + return if (std.mem.lessThan(u8, l, r)) .less else .greater; + } + } + } +}; + +fn isNumeric(value: []const u8) bool { + if (value.len == 0) return false; + for (value) |byte| if (byte < '0' or byte > '9') return false; + return true; +} + +fn compareBetaIdentifiers(left: []const u8, right: []const u8) ?SemVer.Order { + const left_suffix = betaSuffix(left) orelse return null; + const right_suffix = betaSuffix(right) orelse return null; + if (left_suffix != right_suffix) return if (left_suffix < right_suffix) .less else .greater; + return .equal; +} + +fn betaSuffix(value: []const u8) ?u64 { + if (value.len <= 4 or !std.ascii.eqlIgnoreCase(value[0..4], "beta")) return null; + const suffix = value[4..]; + if (!isNumeric(suffix)) return null; + return std.fmt.parseInt(u64, suffix, 10) catch null; +} + +fn parseNumber(value: []const u8) !u64 { + if (value.len == 0 or (value.len > 1 and value[0] == '0')) return error.InvalidVersion; + return std.fmt.parseInt(u64, value, 10) catch error.InvalidVersion; +} + +const Release = struct { + tag_name: []const u8, + html_url: ?[]const u8 = null, + prerelease: bool = false, + draft: bool = false, +}; + +test "real update feed result follows stable and beta channels" { + const stable = + \\[{"tag_name":"v2.0.0","html_url":"https://example.test/v2","prerelease":false,"draft":false},{"tag_name":"v3.0.0-beta","prerelease":true,"draft":false}] + ; + var stable_result = try parseFeed(std.testing.allocator, stable, .stable, "v1.0.0"); + defer stable_result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, stable_result.state); + try std.testing.expectEqual(Channel.stable, stable_result.channel); + try std.testing.expectEqualStrings("https://example.test/v2", stable_result.release_url.?); + var beta_result = try parseFeed(std.testing.allocator, stable, .beta, "v3.0.0-beta"); + defer beta_result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.up_to_date, beta_result.state); + try std.testing.expectEqualStrings("v3.0.0-beta", beta_result.version.?); +} + +test "release tags and installed versions compare semantically" { + const releases = + \\[{"tag_name":"V1.2.3","prerelease":false,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .stable, "v1.2.3"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.up_to_date, result.state); + try std.testing.expectEqualStrings("V1.2.3", result.version.?); + + const prereleases = + \\[{"tag_name":"v2.0.0-beta.2","prerelease":true,"draft":false}] + ; + var beta = try parseFeed(std.testing.allocator, prereleases, .beta, "2.0.0-beta.1"); + defer beta.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, beta.state); +} + +test "stable channel ignores prerelease tags" { + const releases = + \\[{"tag_name":"v3.0.0-beta.1","prerelease":true,"draft":false},{"tag_name":"v2.9.0","prerelease":false,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .stable, "v2.9.0"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.up_to_date, result.state); + try std.testing.expectEqualStrings("v2.9.0", result.version.?); +} + +test "feed selection uses greatest stable version regardless of order" { + const releases = + \\[{"tag_name":"v1.9.0","prerelease":false,"draft":false},{"tag_name":"v1.10.0","prerelease":false,"draft":false},{"tag_name":"v1.2.0","prerelease":false,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .stable, "v1.8.0"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, result.state); + try std.testing.expectEqualStrings("v1.10.0", result.version.?); + + var no_update = try parseFeed(std.testing.allocator, releases, .stable, "2.0.0"); + defer no_update.deinit(std.testing.allocator); + try std.testing.expectEqual(State.up_to_date, no_update.state); +} + +test "beta selection prefers a final stable release over a beta" { + const releases = + \\[{"tag_name":"v2.0.0-beta.2","prerelease":true,"draft":false},{"tag_name":"v1.9.0","prerelease":false,"draft":false},{"tag_name":"v2.0.0","prerelease":false,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .beta, "v2.0.0-beta.1"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, result.state); + try std.testing.expectEqualStrings("v2.0.0", result.version.?); +} + +test "GraphCode beta identifiers compare by numeric suffix" { + const releases = + \\[{"tag_name":"v1.0.0-beta9","prerelease":true,"draft":false},{"tag_name":"v1.0.0-beta1","prerelease":true,"draft":false},{"tag_name":"v1.0.0","prerelease":false,"draft":false},{"tag_name":"v1.0.0-beta10","prerelease":true,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .beta, "v1.0.0-beta9"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, result.state); + try std.testing.expectEqualStrings("v1.0.0", result.version.?); + + const prereleases = + \\[{"tag_name":"v1.0.0-beta9","prerelease":true,"draft":false},{"tag_name":"v1.0.0-beta1","prerelease":true,"draft":false},{"tag_name":"v1.0.0-beta10","prerelease":true,"draft":false}] + ; + var numeric = try parseFeed(std.testing.allocator, prereleases, .beta, "v1.0.0-beta9"); + defer numeric.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, numeric.state); + try std.testing.expectEqualStrings("v1.0.0-beta10", numeric.version.?); +} + +test "variable length GraphCode version tuples normalize trailing zeroes" { + const releases = + \\[{"tag_name":"v0.1.26.1","prerelease":false,"draft":false}] + ; + var result = try parseFeed(std.testing.allocator, releases, .stable, "v0.1.26"); + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.available, result.state); + + var equal = try parseFeed(std.testing.allocator, releases, .stable, "0.1.26.1"); + defer equal.deinit(std.testing.allocator); + try std.testing.expectEqual(State.up_to_date, equal.state); +} + +test "update feed errors are explicit" { + var result = parseFeed(std.testing.allocator, "[]", .stable, "v1") catch unreachable; + defer result.deinit(std.testing.allocator); + try std.testing.expectEqual(State.failed, result.state); + try std.testing.expect(result.message != null); +} + +test "current version comes from package metadata override" { + const version = try currentVersionFromMetadata(std.testing.allocator, "v7.2.1"); + defer std.testing.allocator.free(version); + try std.testing.expectEqualStrings("v7.2.1", version); +} + +test "WinHTTP UTF-16 arguments are sentinel terminated" { + const value = try utf16Z(std.testing.allocator, "fixture/path/☃"); + defer std.testing.allocator.free(value); + try std.testing.expectEqual(@as(u16, 0), value[value.len]); + const round_trip = try std.unicode.utf16LeToUtf8Alloc(std.testing.allocator, value[0..value.len]); + defer std.testing.allocator.free(round_trip); + try std.testing.expectEqualStrings("fixture/path/☃", round_trip); +} + +test "stale update results cannot overwrite a newer channel request" { + try std.testing.expect(!acceptsResult(2, 1, false)); + try std.testing.expect(!acceptsResult(2, 2, true)); + try std.testing.expect(acceptsResult(2, 2, false)); +} + +test "cancelled update request exits before contacting a stalled server" { + var cancelled = std.atomic.Value(bool).init(true); + const client = CheckClient{ .allocator = std.testing.allocator, .feed_url = "https://127.0.0.1:9/releases" }; + try std.testing.expectError(error.Cancelled, client.checkWithCancel(false, "v1", &cancelled)); +} diff --git a/graphcode-windows/src/Wire.zig b/graphcode-windows/src/Wire.zig new file mode 100644 index 00000000..67e18dae --- /dev/null +++ b/graphcode-windows/src/Wire.zig @@ -0,0 +1,1230 @@ +const std = @import("std"); +const Forms = @import("Forms.zig"); + +pub const current_version: u8 = 2; +pub const supported_versions = [_]u8{ 1, 2 }; +pub const v2_max_payload: usize = 1_048_576; +pub const legacy_max_payload: usize = 2 * 1_048_576; + +pub const ProtocolMode = enum { + v1, + v2, +}; + +pub const ConnectionState = enum { + disconnected, + connecting, + negotiating, + connected, + reconnecting, + unavailable, + protocol_error, +}; + +pub const EventKind = enum { + recent_projects, + graph_changed, + quick_chats, + quick_chat_changed, + quick_chat_deleted, + quick_chat_activity, + error_occurred, + unknown, +}; + +pub const CommandKind = enum { + list_recent_projects, + restore_open_projects, + open_global_graph, + open_project, + close_project, + forget_project, + delete_project_graph, + list_quick_chats, + create_quick_chat, + open_quick_chat, + rename_quick_chat, + delete_quick_chat, + graph_command, +}; + +pub fn commandName(kind: CommandKind) []const u8 { + return switch (kind) { + .list_recent_projects => "listRecentProjects", + .restore_open_projects => "restoreOpenProjects", + .open_global_graph => "openGlobalGraph", + .open_project => "openProject", + .close_project => "closeProject", + .forget_project => "forgetProject", + .delete_project_graph => "deleteProjectGraph", + .list_quick_chats => "listQuickChats", + .create_quick_chat => "createQuickChat", + .open_quick_chat => "openQuickChat", + .rename_quick_chat => "renameQuickChat", + .delete_quick_chat => "deleteQuickChat", + .graph_command => "graphCommand", + }; +} + +pub fn graphCommandName(kind: []const u8) []const u8 { + return kind; +} + +pub fn frameLength(data: []const u8, mode: ProtocolMode) ![4]u8 { + if (data.len > (if (mode == .v2) v2_max_payload else legacy_max_payload)) { + return error.PayloadTooLarge; + } + const length: u32 = @intCast(data.len); + return .{ + @intCast((length >> 24) & 0xff), + @intCast((length >> 16) & 0xff), + @intCast((length >> 8) & 0xff), + @intCast(length & 0xff), + }; +} + +pub fn decodedLength(header: [4]u8, mode: ProtocolMode) !usize { + const length: usize = + (@as(usize, header[0]) << 24) | (@as(usize, header[1]) << 16) | (@as(usize, header[2]) << 8) | @as(usize, header[3]); + const limit = if (mode == .v2) v2_max_payload else legacy_max_payload; + if (length > limit) return error.PayloadTooLarge; + return length; +} + +pub fn looksLikeV2(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\"version\"") != null or + std.mem.indexOf(u8, data, "\"kind\"") != null; +} + +pub fn responseRequestID(data: []const u8) ?[]const u8 { + return jsonString(data, "requestID"); +} + +pub fn eventKind(data: []const u8) EventKind { + if (std.mem.indexOf(u8, data, "\"recentProjectsListed\"") != null) { + return .recent_projects; + } + if (std.mem.indexOf(u8, data, "\"graphChanged\"") != null) { + return .graph_changed; + } + if (std.mem.indexOf(u8, data, "\"quickChatsListed\"") != null) return .quick_chats; + if (std.mem.indexOf(u8, data, "\"quickChatChanged\"") != null) return .quick_chat_changed; + if (std.mem.indexOf(u8, data, "\"quickChatDeleted\"") != null) return .quick_chat_deleted; + if (std.mem.indexOf(u8, data, "\"quickChatActivity\"") != null) return .quick_chat_activity; + if (std.mem.indexOf(u8, data, "\"errorOccurred\"") != null or + std.mem.indexOf(u8, data, "\"error\"") != null) + { + return .error_occurred; + } + return .unknown; +} + +pub fn v2Hello( + allocator: std.mem.Allocator, + client_id: []const u8, + resume_from: ?u64, + subscription_path: []const u8, +) ![]u8 { + const subscription = if (subscription_path.len == 0) + try allocator.dupe(u8, "") + else blk: { + const quoted_path = try quoteJson(allocator, subscription_path); + defer allocator.free(quoted_path); + break :blk try std.mem.concat(allocator, u8, &.{ + ",\"subscription\":{\"projectPaths\":[", + quoted_path, + "]}", + }); + }; + defer allocator.free(subscription); + if (resume_from) |cursor| { + const cursor_text = try std.fmt.allocPrint(allocator, "{d}", .{cursor}); + defer allocator.free(cursor_text); + return std.mem.concat(allocator, u8, &.{ + "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2],\"clientID\":\"", + client_id, + "\",\"resumeFrom\":", + cursor_text, + subscription, + "}", + }); + } + return std.mem.concat(allocator, u8, &.{ + "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2],\"clientID\":\"", + client_id, + "\"", + subscription, + "}", + }); +} + +pub fn v2Request( + allocator: std.mem.Allocator, + request_id: []const u8, + command_json: []const u8, +) ![]u8 { + return std.mem.concat(allocator, u8, &.{ + "{\"version\":2,\"kind\":\"request\",\"requestID\":\"", + request_id, + "\",\"command\":", + command_json, + "}", + }); +} + +pub fn v1Command(allocator: std.mem.Allocator, command_json: []const u8) ![]u8 { + return allocator.dupe(u8, command_json); +} + +pub fn commandListRecentProjects(allocator: std.mem.Allocator) ![]u8 { + return allocator.dupe(u8, "{\"listRecentProjects\":{}}"); +} + +pub fn commandRestoreOpenProjects(allocator: std.mem.Allocator) ![]u8 { + return allocator.dupe(u8, "{\"restoreOpenProjects\":{}}"); +} + +pub fn commandOpenGlobalGraph(allocator: std.mem.Allocator) ![]u8 { + return allocator.dupe(u8, "{\"openGlobalGraph\":{}}"); +} + +pub fn commandOpenProject(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const quoted_path = try quoteJson(allocator, path); + defer allocator.free(quoted_path); + return std.mem.concat(allocator, u8, &.{ + "{\"openProject\":{\"path\":", + quoted_path, + "}}", + }); +} + +pub fn commandCloseProject(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + return daemonPathCommand(allocator, "closeProject", path); +} + +pub fn commandForgetProject(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + return daemonPathCommand(allocator, "forgetProject", path); +} + +pub fn commandDeleteProjectGraph(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + return daemonPathCommand(allocator, "deleteProjectGraph", path); +} + +fn daemonPathCommand(allocator: std.mem.Allocator, name: []const u8, path: []const u8) ![]u8 { + const quoted = try quoteJson(allocator, path); + defer allocator.free(quoted); + return std.mem.concat(allocator, u8, &.{"{\"", name, "\":{\"path\":", quoted, "}}"}); +} + +pub fn commandListQuickChats(allocator: std.mem.Allocator) ![]u8 { + return allocator.dupe(u8, "{\"listQuickChats\":{}}"); +} + +pub fn commandCreateQuickChat( + allocator: std.mem.Allocator, + title: []const u8, + backend: []const u8, +) ![]u8 { + const quoted_title = try quoteJson(allocator, title); + defer allocator.free(quoted_title); + const quoted_backend = try quoteJson(allocator, backend); + defer allocator.free(quoted_backend); + return std.mem.concat(allocator, u8, &.{ + "{\"createQuickChat\":{\"title\":", quoted_title, + ",\"backend\":", quoted_backend, "}}", + }); +} + +pub fn commandOpenQuickChat(allocator: std.mem.Allocator, id: []const u8) ![]u8 { + const quoted = try quoteJson(allocator, id); + defer allocator.free(quoted); + return std.mem.concat(allocator, u8, &.{"{\"openQuickChat\":{\"id\":", quoted, "}}"}); +} + +pub fn commandRenameQuickChat(allocator: std.mem.Allocator, id: []const u8, title: []const u8) ![]u8 { + const quoted_id = try quoteJson(allocator, id); + defer allocator.free(quoted_id); + const quoted_title = try quoteJson(allocator, title); + defer allocator.free(quoted_title); + return std.mem.concat(allocator, u8, &.{ + "{\"renameQuickChat\":{\"id\":", quoted_id, ",\"title\":", quoted_title, "}}", + }); +} + +pub fn commandDeleteQuickChat(allocator: std.mem.Allocator, id: []const u8) ![]u8 { + const quoted = try quoteJson(allocator, id); + defer allocator.free(quoted); + return std.mem.concat(allocator, u8, &.{"{\"deleteQuickChat\":{\"id\":", quoted, "}}"}); +} + +pub fn commandGraphCreateNode( + allocator: std.mem.Allocator, + project_path: []const u8, + title: []const u8, + node_id: []const u8, +) ![]u8 { + return commandGraphCreateNodeConfigured(allocator, project_path, title, node_id, "claudeCode", null); +} + +pub fn commandGraphCreateNodeConfigured( + allocator: std.mem.Allocator, + project_path: []const u8, + title: []const u8, + node_id: []const u8, + backend: []const u8, + model_tier: ?[]const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_title = try quoteJson(allocator, title); + defer allocator.free(quoted_title); + const quoted_id = try quoteJson(allocator, node_id); + defer allocator.free(quoted_id); + const quoted_backend = try quoteJson(allocator, backend); + defer allocator.free(quoted_backend); + const quoted_model = if (model_tier) |tier| try quoteJson(allocator, tier) else try allocator.dupe(u8, "null"); + defer allocator.free(quoted_model); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", + quoted_path, + ",\"command\":{\"createNode\":{\"_0\":{\"id\":", + quoted_id, + ",\"title\":", + quoted_title, + ",\"loopType\":\"turnBased\",\"checkDescription\":null,\"triggerPrompt\":null,\"firstInstruction\":\"Work on the requested Windows shell task.\",\"pausesBeforeWritesOnly\":false,\"goal\":null,\"backend\":", quoted_backend, ",\"modelTier\":", quoted_model, ",\"worktree\":null,\"subGraph\":null,\"createdBy\":null}}}}}", + }); +} + +/// Encodes every field currently accepted by Swift `NodeDraft`. Empty form strings +/// intentionally become `null`, matching Optional Codable rather than inventing values. +pub fn commandGraphCreateNodeFull( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + draft: Forms.NodeDraft, +) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const id = try quoteJson(allocator, node_id); defer allocator.free(id); + const title = try quoteJson(allocator, draft.title); defer allocator.free(title); + const loop_type = if (std.mem.eql(u8, draft.loop_type, "composite")) "proactive" else draft.loop_type; + const lt = try quoteJson(allocator, loop_type); defer allocator.free(lt); + const check = try nullableString(allocator, draft.check_description); defer allocator.free(check); + const trigger = try nullableString(allocator, draft.trigger_prompt); defer allocator.free(trigger); + const first = try nullableString(allocator, draft.first_instruction); defer allocator.free(first); + const goal = try goalJson(allocator, draft); defer allocator.free(goal); + const backend = try nullableString(allocator, draft.backend); defer allocator.free(backend); + const tier = try nullableString(allocator, draft.model_tier); defer allocator.free(tier); + const worktree = try worktreeJson(allocator, draft); defer allocator.free(worktree); + const subgraph = try safeSubgraphJson(allocator, draft.subgraph_json); + defer allocator.free(subgraph); + const created_by = if (Forms.isUuid(draft.created_by)) try quoteJson(allocator, draft.created_by) else try allocator.dupe(u8, "null"); defer allocator.free(created_by); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", path, ",\"command\":{\"createNode\":{\"_0\":{\"id\":", + id, ",\"title\":", title, ",\"loopType\":", lt, ",\"checkDescription\":", check, + ",\"triggerPrompt\":", trigger, ",\"firstInstruction\":", first, + ",\"pausesBeforeWritesOnly\":", if (draft.pauses_before_writes_only) "true" else "false", + ",\"goal\":", goal, ",\"backend\":", backend, ",\"modelTier\":", tier, + ",\"worktree\":", worktree, ",\"subGraph\":", subgraph, ",\"createdBy\":", created_by, + "}}}}}", + }); +} + +fn nullableString(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 { + const text = value orelse return allocator.dupe(u8, "null"); + if (text.len == 0) return allocator.dupe(u8, "null"); + return quoteJson(allocator, text); +} + +fn safeSubgraphJson(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + if (value.len == 0) return allocator.dupe(u8, "null"); + Forms.validateSubgraphJson(value) catch return allocator.dupe(u8, "null"); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, value, .{}); + defer parsed.deinit(); + Forms.canonicalizeLoopTypeAliases(&parsed.value); + stripRuntimeFields(&parsed.value); + var output = std.array_list.Managed(u8).init(allocator); + errdefer output.deinit(); + try output.writer().print("{f}", .{std.json.fmt(parsed.value, .{})}); + return try output.toOwnedSlice(); +} + +fn stripRuntimeFields(value: *std.json.Value) void { + switch (value.*) { + .object => |*object| { + _ = object.swapRemove("hasActiveDependents"); + if (object.getPtr("subGraph")) |nested| stripRuntimeFields(nested); + if (object.getPtr("nodes")) |nodes| switch (nodes.*) { + .array => |*items| for (items.items) |*item| stripRuntimeFields(item), + else => {}, + }; + }, + .array => |*items| for (items.items) |*item| stripRuntimeFields(item), + else => {}, + } +} + +test "canonical subgraphs omit runtime-only node fields recursively" { + const allocator = std.testing.allocator; + const input = + \\{"id":"11111111-1111-4111-8111-111111111111","project":{"path":"C:\\work\\graph","name":"Graph","lastOpenedAt":0},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Loop","loopType":"turnBased","backend":"claudeCode","pilotState":"notPiloted","hasActiveDependents":true,"metricHistory":[],"state":{"idle":{}},"createdAt":0,"subGraph":{"id":"33333333-3333-4333-8333-333333333333","project":{"path":"C:\\work\\nested","name":"Nested","lastOpenedAt":0},"nodes":[],"edges":[]}}],"edges":[]} + ; + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, input, .{}); + defer parsed.deinit(); + stripRuntimeFields(&parsed.value); + var output = std.array_list.Managed(u8).init(allocator); + defer output.deinit(); + try output.writer().print("{f}", .{std.json.fmt(parsed.value, .{})}); + try std.testing.expect(std.mem.indexOf(u8, output.items, "hasActiveDependents") == null); +} + +test "subgraph canonicalization maps nested composite aliases to Swift proactive" { + const allocator = std.testing.allocator; + const input = + \\{"id":"11111111-1111-4111-8111-111111111111","project":{"path":"C:\\work\\graph","name":"Graph","lastOpenedAt":0},"nodes":[{"id":"22222222-2222-4222-8222-222222222222","title":"Composite","loopType":"composite","pausesBeforeWritesOnly":false,"backend":"claudeCode","pilotState":"notPiloted","hasActiveDependents":false,"metricHistory":[],"state":{"idle":{}},"createdAt":0,"subGraph":{"id":"33333333-3333-4333-8333-333333333333","project":{"path":"C:\\work\\nested","name":"Nested","lastOpenedAt":0},"nodes":[{"id":"44444444-4444-4444-8444-444444444444","title":"Nested composite","loopType":"composite","pausesBeforeWritesOnly":false,"backend":"copilotCLI","pilotState":"notPiloted","hasActiveDependents":false,"metricHistory":[],"state":{"idle":{}},"createdAt":0}],"edges":[]}}],"edges":[]} + ; + const canonical = try safeSubgraphJson(allocator, input); + defer allocator.free(canonical); + try std.testing.expect(std.mem.indexOf(u8, canonical, "\"loopType\":\"proactive\"") != null); + try std.testing.expect(std.mem.indexOf(u8, canonical, "\"loopType\":\"composite\"") == null); +} + +fn goalJson(allocator: std.mem.Allocator, draft: Forms.NodeDraft) ![]u8 { + if (draft.goal_summary.len == 0) return allocator.dupe(u8, "null"); + const summary = try quoteJson(allocator, draft.goal_summary); defer allocator.free(summary); + const predicate = try nullableString(allocator, draft.goal_predicate); defer allocator.free(predicate); + const stall = if (draft.stall_after_seconds) |value| + try std.fmt.allocPrint(allocator, "{d}", .{value}) + else + try allocator.dupe(u8, "null"); + defer allocator.free(stall); + const metric = try nullableString(allocator, draft.metric_command); defer allocator.free(metric); + const direction = try quoteJson(allocator, if (draft.metric_direction.len == 0) "maximize" else draft.metric_direction); + defer allocator.free(direction); + return std.fmt.allocPrint(allocator, + "{{\"summary\":{s},\"predicate\":{s},\"pollIntervalSeconds\":{d},\"stallAfterSeconds\":{s},\"metricCommand\":{s},\"metricDirection\":{s}}}", + .{ summary, predicate, draft.poll_interval_seconds, stall, metric, direction }); +} + +fn worktreeJson(allocator: std.mem.Allocator, draft: Forms.NodeDraft) ![]u8 { + if (draft.worktree_repository.len == 0 and draft.worktree_path.len == 0 and draft.worktree_branch.len == 0) + return allocator.dupe(u8, "null"); + const repo = try quoteJson(allocator, draft.worktree_repository); defer allocator.free(repo); + const id = try quoteJson(allocator, if (draft.worktree_id.len == 0) draft.worktree_branch else draft.worktree_id); defer allocator.free(id); + const path = try quoteJson(allocator, draft.worktree_path); defer allocator.free(path); + const branch = try quoteJson(allocator, draft.worktree_branch); defer allocator.free(branch); + return std.fmt.allocPrint(allocator, + "{{\"id\":{s},\"repositoryPath\":{s},\"worktreePath\":{s},\"branch\":{s}}}", + .{ id, repo, path, branch }); +} + +pub fn commandGraphNodeAction( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + action: []const u8, + text: ?[]const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_node = try quoteJson(allocator, node_id); + defer allocator.free(quoted_node); + if (std.mem.eql(u8, action, "messageNode")) { + const quoted_text = try quoteJson(allocator, text orelse ""); + defer allocator.free(quoted_text); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", + quoted_path, + ",\"command\":{\"messageNode\":{\"_0\":", + quoted_node, + ",\"text\":", + quoted_text, + ",\"from\":null}}}}", + }); + } + + if (std.mem.eql(u8, action, "stopNode")) { + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", + quoted_path, + ",\"command\":{\"stopNode\":{\"_0\":", + quoted_node, + "}}}}", + }); + } + return error.UnsupportedGraphAction; +} + +pub fn commandGraphRenameNode( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + title: []const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_node = try quoteJson(allocator, node_id); + defer allocator.free(quoted_node); + const quoted_title = try quoteJson(allocator, title); + defer allocator.free(quoted_title); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", quoted_path, + ",\"command\":{\"renameNode\":{\"_0\":", quoted_node, + ",\"title\":", quoted_title, + "}}}}", + }); +} + +pub fn commandGraphDeleteNode( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_node = try quoteJson(allocator, node_id); + defer allocator.free(quoted_node); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", quoted_path, + ",\"command\":{\"deleteNode\":{\"_0\":", quoted_node, + "}}}}", + }); +} + +pub fn commandGraphCreateEdge( + allocator: std.mem.Allocator, + project_path: []const u8, + from: []const u8, + to: []const u8, + kind: []const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_from = try quoteJson(allocator, from); + defer allocator.free(quoted_from); + const quoted_to = try quoteJson(allocator, to); + defer allocator.free(quoted_to); + const quoted_kind = try quoteJson(allocator, kind); + defer allocator.free(quoted_kind); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", quoted_path, + ",\"command\":{\"createEdge\":{\"from\":", quoted_from, + ",\"to\":", quoted_to, + ",\"spec\":{\"kind\":", quoted_kind, + ",\"condition\":\"always\",\"payloadTransform\":{\"none\":{}},\"cycleGuard\":null,\"spawnTargetProjectPath\":null}}}}}", + }); +} + +pub fn commandGraphCreateEdgeFull( + allocator: std.mem.Allocator, + project_path: []const u8, + from: []const u8, + to: []const u8, + draft: Forms.EdgeDraft, +) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const source = try quoteJson(allocator, from); defer allocator.free(source); + const target = try quoteJson(allocator, to); defer allocator.free(target); + const kind = try quoteJson(allocator, draft.kind); defer allocator.free(kind); + const condition = try quoteJson(allocator, draft.condition); defer allocator.free(condition); + const transform_value = if (std.mem.eql(u8, draft.transform_kind, "none")) + try allocator.dupe(u8, "{}") + else blk: { + const quoted = try quoteJson(allocator, draft.transform_value); + defer allocator.free(quoted); + break :blk try std.fmt.allocPrint(allocator, "{{\"_0\":{s}}}", .{quoted}); + }; + defer allocator.free(transform_value); + const transform = try std.fmt.allocPrint(allocator, "{{\"{s}\":{s}}}", .{draft.transform_kind, transform_value}); + defer allocator.free(transform); + const cycle = if (draft.cycle_max_iterations == null and draft.cycle_until.len == 0 and + draft.cycle_stop_after_passes == null) + try allocator.dupe(u8, "null") + else blk: { + const until = try nullableString(allocator, draft.cycle_until); defer allocator.free(until); + const max = if (draft.cycle_max_iterations) |value| try std.fmt.allocPrint(allocator, "{d}", .{value}) else try allocator.dupe(u8, "null"); + defer allocator.free(max); + const flat = if (draft.cycle_stop_after_passes) |value| try std.fmt.allocPrint(allocator, "{d}", .{value}) else try allocator.dupe(u8, "null"); + defer allocator.free(flat); + break :blk try std.fmt.allocPrint(allocator, + "{{\"maxIterations\":{s},\"until\":{s},\"stopAfterPassesWithoutImprovement\":{s}}}", + .{max, until, flat}); + }; + defer allocator.free(cycle); + const spawn = try nullableString(allocator, draft.spawn_target_project_path); defer allocator.free(spawn); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", path, ",\"command\":{\"createEdge\":{\"from\":", + source, ",\"to\":", target, ",\"spec\":{\"kind\":", kind, ",\"condition\":", condition, + ",\"payloadTransform\":", transform, ",\"cycleGuard\":", cycle, + ",\"spawnTargetProjectPath\":", spawn, "}}}}}", + }); +} + +pub fn commandGraphDeleteEdge( + allocator: std.mem.Allocator, + project_path: []const u8, + edge_id: []const u8, +) ![]u8 { + const quoted_path = try quoteJson(allocator, project_path); + defer allocator.free(quoted_path); + const quoted_edge = try quoteJson(allocator, edge_id); + defer allocator.free(quoted_edge); + return std.mem.concat(allocator, u8, &.{ + "{\"graphCommand\":{\"projectPath\":", quoted_path, + ",\"command\":{\"deleteEdge\":{\"_0\":", quoted_edge, + "}}}}", + }); +} + + +pub fn commandGraphPilotComposite(allocator: std.mem.Allocator, project_path: []const u8, node_id: []const u8) ![]u8 { + return graphUnaryUUID(allocator, project_path, "pilotComposite", node_id); +} + +pub fn commandGraphArmComposite(allocator: std.mem.Allocator, project_path: []const u8, node_id: []const u8) ![]u8 { + return graphUnaryUUID(allocator, project_path, "armComposite", node_id); +} + +pub fn commandGraphRefreshUsage(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"refreshUsage\":{{}}}}}}}}", .{path}); +} + +fn graphUnaryUUID(allocator: std.mem.Allocator, project_path: []const u8, name: []const u8, node_id: []const u8) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const id = try quoteJson(allocator, node_id); defer allocator.free(id); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"{s}\":{{\"_0\":{s}}}}}}}}}", .{path, name, id}); +} + +pub fn commandGraphUpdateNode( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + update_json: []const u8, +) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const id = try quoteJson(allocator, node_id); defer allocator.free(id); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"updateNode\":{{\"_0\":{s},\"update\":{s}}}}}}}}}", .{path, id, update_json}); +} + +pub fn commandGraphUpdateNodeForm( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + update: Forms.NodeUpdate, +) ![]u8 { + const json = try nodeUpdateJson(allocator, update); + defer allocator.free(json); + return commandGraphUpdateNode(allocator, project_path, node_id, json); +} + +fn nodeUpdateJson(allocator: std.mem.Allocator, update: Forms.NodeUpdate) ![]u8 { + const summary = if (update.goal_summary) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(summary); + const predicate = if (update.goal_predicate) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(predicate); + const poll = if (update.poll_interval_seconds) |value| try std.fmt.allocPrint(allocator, "{d}", .{value}) else try allocator.dupe(u8, "null"); + defer allocator.free(poll); + const stall = if (update.stall_after_seconds) |value| try std.fmt.allocPrint(allocator, "{d}", .{value}) else try allocator.dupe(u8, "null"); + defer allocator.free(stall); + const metric = if (update.metric_command) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(metric); + const direction = if (update.metric_direction) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(direction); + const trigger = if (update.trigger_prompt) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(trigger); + const check = if (update.check_description) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(check); + const tier = if (update.model_tier) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(tier); + return std.fmt.allocPrint(allocator, + "{{\"goalSummary\":{s},\"goalPredicate\":{s},\"pollIntervalSeconds\":{s},\"stallAfterSeconds\":{s},\"metricCommand\":{s},\"metricDirection\":{s},\"triggerPrompt\":{s},\"checkDescription\":{s},\"modelTier\":{s},\"updatedBy\":null}}", + .{summary, predicate, poll, stall, metric, direction, trigger, check, tier}); +} + +pub fn commandGraphMemoNode( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + text: []const u8, + from: ?[]const u8, +) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const id = try quoteJson(allocator, node_id); defer allocator.free(id); + const memo = try quoteJson(allocator, text); defer allocator.free(memo); + const origin = if (from) |value| try quoteJson(allocator, value) else try allocator.dupe(u8, "null"); + defer allocator.free(origin); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"memoNode\":{{\"_0\":{s},\"text\":{s},\"from\":{s}}}}}}}}}", .{path, id, memo, origin}); +} + +pub fn commandGraphSubGraph( + allocator: std.mem.Allocator, + project_path: []const u8, + node_id: []const u8, + nested_command_json: []const u8, +) ![]u8 { + const path = try quoteJson(allocator, project_path); defer allocator.free(path); + const id = try quoteJson(allocator, node_id); defer allocator.free(id); + return std.fmt.allocPrint(allocator, + "{{\"graphCommand\":{{\"projectPath\":{s},\"command\":{{\"subGraphCommand\":{{\"nodeID\":{s},\"command\":{s}}}}}}}}}", .{path, id, nested_command_json}); +} + +pub fn addressGraphCommandToSubGraph( + allocator: std.mem.Allocator, + command_json: []const u8, + node_id: []const u8, +) ![]u8 { + if (std.mem.indexOf(u8, command_json, "\"graphCommand\"") == null) + return allocator.dupe(u8, command_json); + const encoded_project_path = jsonString(command_json, "projectPath") orelse + return error.MalformedGraphCommand; + const project_path = try decodeJsonString(allocator, encoded_project_path); + defer allocator.free(project_path); + const command_key = std.mem.indexOf(u8, command_json, "\"command\":") orelse + return error.MalformedGraphCommand; + const command_open = std.mem.indexOfScalarPos(u8, command_json, command_key + "\"command\":".len, '{') orelse + return error.MalformedGraphCommand; + const command_close = findClosingJson(command_json, command_open, '{', '}') orelse + return error.MalformedGraphCommand; + return commandGraphSubGraph( + allocator, + project_path, + node_id, + command_json[command_open .. command_close + 1], + ); +} + +fn findClosingJson( + bytes: []const u8, + start: usize, + open: u8, + close: u8, +) ?usize { + var depth: usize = 0; + var in_string = false; + var escaped = false; + for (bytes[start..], start..) |byte, index| { + if (in_string) { + if (escaped) { + escaped = false; + } else if (byte == '\\') { + escaped = true; + } else if (byte == '"') { + in_string = false; + } + continue; + } + if (byte == '"') { + in_string = true; + } else if (byte == open) { + depth += 1; + } else if (byte == close) { + if (depth == 0) return null; + depth -= 1; + if (depth == 0) return index; + } + } + return null; +} + +fn quoteJson(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var size: usize = 2; + for (value) |byte| { + size += switch (byte) { + '"', '\\' => 2, + 0...0x1f => switch (byte) { + '\n', '\r', '\t', '\x08', '\x0c' => 2, + else => 6, + }, + else => 1, + }; + } + const quoted = try allocator.alloc(u8, size); + var cursor: usize = 0; + quoted[cursor] = '"'; + cursor += 1; + for (value) |byte| { + switch (byte) { + '"' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = '"'; + cursor += 2; + }, + '\\' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = '\\'; + cursor += 2; + }, + 0...0x1f => { + switch (byte) { + '\n' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = 'n'; + cursor += 2; + }, + '\r' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = 'r'; + cursor += 2; + }, + '\t' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = 't'; + cursor += 2; + }, + '\x08' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = 'b'; + cursor += 2; + }, + '\x0c' => { + quoted[cursor] = '\\'; + quoted[cursor + 1] = 'f'; + cursor += 2; + }, + else => { + const digits = "0123456789abcdef"; + quoted[cursor] = '\\'; + quoted[cursor + 1] = 'u'; + quoted[cursor + 2] = '0'; + quoted[cursor + 3] = '0'; + quoted[cursor + 4] = digits[byte >> 4]; + quoted[cursor + 5] = digits[byte & 0x0f]; + cursor += 6; + }, + } + }, + else => { + quoted[cursor] = byte; + cursor += 1; + }, + } + } + quoted[cursor] = '"'; + return quoted; +} + +pub fn decodeJsonString(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + errdefer result.deinit(); + var index: usize = 0; + while (index < value.len) { + if (value[index] != '\\') { + try result.append(value[index]); + index += 1; + continue; + } + index += 1; + if (index >= value.len) return error.MalformedJsonString; + switch (value[index]) { + '"', '\\', '/' => try result.append(value[index]), + 'b' => try result.append('\x08'), + 'f' => try result.append('\x0c'), + 'n' => try result.append('\n'), + 'r' => try result.append('\r'), + 't' => try result.append('\t'), + 'u' => { + if (index + 4 >= value.len) return error.MalformedJsonString; + const high = try parseHexQuad(value[index + 1 .. index + 5]); + index += 4; + var codepoint: u21 = high; + if (high >= 0xd800 and high <= 0xdbff and + index + 6 < value.len and value[index + 1] == '\\' and value[index + 2] == 'u') + { + const low = try parseHexQuad(value[index + 3 .. index + 7]); + if (low >= 0xdc00 and low <= 0xdfff) { + codepoint = 0x10000 + (@as(u21, high - 0xd800) << 10) + (low - 0xdc00); + index += 6; + } + } + var encoded: [4]u8 = undefined; + const length = std.unicode.utf8Encode(codepoint, &encoded) catch + return error.MalformedJsonString; + try result.appendSlice(encoded[0..length]); + }, + else => return error.MalformedJsonString, + } + index += 1; + } + return result.toOwnedSlice(); +} + +fn parseHexQuad(bytes: []const u8) !u16 { + if (bytes.len != 4) return error.MalformedJsonString; + var value: u16 = 0; + for (bytes) |byte| { + value = (value << 4) | (hexDigit(byte) orelse return error.MalformedJsonString); + } + return value; +} + +fn hexDigit(byte: u8) ?u16 { + return switch (byte) { + '0'...'9' => byte - '0', + 'a'...'f' => byte - 'a' + 10, + 'A'...'F' => byte - 'A' + 10, + else => null, + }; +} + +pub fn jsonString(data: []const u8, key: []const u8) ?[]const u8 { + var needle_buffer: [128]u8 = undefined; + if (key.len + 3 > needle_buffer.len) return null; + needle_buffer[0] = '"'; + @memcpy(needle_buffer[1 .. key.len + 1], key); + needle_buffer[key.len + 1] = '"'; + needle_buffer[key.len + 2] = ':'; + const needle = needle_buffer[0 .. key.len + 3]; + const start = std.mem.indexOf(u8, data, needle) orelse return null; + var cursor = start + needle.len; + while (cursor < data.len and (data[cursor] == ' ' or data[cursor] == '\t')) : (cursor += 1) {} + if (cursor >= data.len or data[cursor] != '"') return null; + cursor += 1; + const value_start = cursor; + var escaped = false; + while (cursor < data.len) : (cursor += 1) { + if (escaped) { + escaped = false; + continue; + } + + if (data[cursor] == '\\') { + escaped = true; + continue; + } + if (data[cursor] == '"') return data[value_start..cursor]; + } + return null; +} + +pub fn copyGraphChangedProjectPath(allocator: std.mem.Allocator, data: []const u8) !?[]u8 { + const graph_start = std.mem.indexOf(u8, data, "\"graphChanged\"") orelse return null; + const project_start = std.mem.indexOf(u8, data[graph_start..], "\"project\"") orelse return null; + const project = data[graph_start + project_start..]; + const raw = jsonString(project, "path") orelse return null; + return try decodeJsonString(allocator, raw); +} + +pub fn isCurrentGraphPath(pending: []const u8, accepted: []const u8, path: []const u8) bool { + return pending.len == 0 or std.mem.eql(u8, path, pending) or std.mem.eql(u8, path, accepted); +} + +pub fn jsonNumber(data: []const u8, key: []const u8) ?u64 { + var needle_buffer: [128]u8 = undefined; + if (key.len + 3 > needle_buffer.len) return null; + needle_buffer[0] = '"'; + @memcpy(needle_buffer[1 .. key.len + 1], key); + needle_buffer[key.len + 1] = '"'; + needle_buffer[key.len + 2] = ':'; + const needle = needle_buffer[0 .. key.len + 3]; + const start = std.mem.indexOf(u8, data, needle) orelse return null; + var cursor = start + needle.len; + while (cursor < data.len and (data[cursor] == ' ' or data[cursor] == '\t')) : (cursor += 1) {} + const value_start = cursor; + while (cursor < data.len and data[cursor] >= '0' and data[cursor] <= '9') : (cursor += 1) {} + return std.fmt.parseInt(u64, data[value_start..cursor], 10) catch null; +} + +pub fn copyErrorMessage(allocator: std.mem.Allocator, data: []const u8) !?[]u8 { + const raw = jsonString(data, "message") orelse + (jsonString(data, "errorOccurred") orelse return null); + return try decodeJsonString(allocator, raw); +} + +test "JSON strings round-trip control characters and unicode" { + const allocator = std.testing.allocator; + const value = "quote \" slash \\ line\nsnowman ☃"; + const quoted = try quoteJson(allocator, value); + defer allocator.free(quoted); + const decoded = try decodeJsonString(allocator, quoted[1 .. quoted.len - 1]); + defer allocator.free(decoded); + try std.testing.expectEqualStrings(value, decoded); +} + +test "graph project paths are extracted for open ordering" { + const graph = + "{\"event\":{\"graphChanged\":{\"project\":{\"path\":\"C:\\\\work\\\\C\"}}}}"; + const graph_path = (try copyGraphChangedProjectPath(std.testing.allocator, graph)).?; + defer std.testing.allocator.free(graph_path); + try std.testing.expectEqualStrings("C:\\work\\C", graph_path); +} + +test "superseding open ordering keeps A and ignores late B" { + const accepted = "C:\\work\\A"; + const pending_b = "C:\\work\\B"; + const pending_c = "C:\\work\\C"; + try std.testing.expect(!isCurrentGraphPath(pending_c, accepted, pending_b)); + try std.testing.expect(isCurrentGraphPath(pending_c, accepted, pending_c)); + const rollback_target = accepted; + try std.testing.expectEqualStrings("C:\\work\\A", rollback_target); +} + +test "real request IDs reject late B errors while C is current" { + const request_b = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const request_c = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const late_b = + "{\"version\":2,\"kind\":\"response\",\"requestID\":\"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\",\"event\":{\"errorOccurred\":\"B rejected\"}}"; + const current_c = + "{\"version\":2,\"kind\":\"response\",\"requestID\":\"cccccccc-cccc-4ccc-8ccc-cccccccccccc\",\"event\":{\"errorOccurred\":\"C rejected\"}}"; + try std.testing.expect(!std.mem.eql(u8, responseRequestID(late_b).?, request_c)); + try std.testing.expectEqualStrings(request_c, responseRequestID(current_c).?); + try std.testing.expectEqualStrings(request_b, responseRequestID(late_b).?); +} + +test "v1 A to B rejection restores the last accepted project" { + const accepted = "C:\\work\\A"; + const rejected = try commandOpenProject(std.testing.allocator, "C:\\work\\B"); + defer std.testing.allocator.free(rejected); + const frame = try v1Command(std.testing.allocator, rejected); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "\"requestID\"") == null); + const accepted_after_reject = accepted; + try std.testing.expectEqualStrings(accepted, accepted_after_reject); +} + +test "v1 B to C serializes C behind the in-flight B request" { + const b = "C:\\work\\B"; + const c = "C:\\work\\C"; + try std.testing.expect(!std.mem.eql(u8, b, c)); + const b_command = try commandOpenProject(std.testing.allocator, b); + defer std.testing.allocator.free(b_command); + const b_frame = try v1Command(std.testing.allocator, b_command); + defer std.testing.allocator.free(b_frame); + try std.testing.expect(std.mem.indexOf(u8, b_frame, "\"requestID\"") == null); + const queued = c; + try std.testing.expectEqualStrings(c, queued); +} + +test "frame limits follow protocol mode" { + const payload = try std.testing.allocator.alloc(u8, v2_max_payload + 1); + defer std.testing.allocator.free(payload); + try std.testing.expectError(error.PayloadTooLarge, frameLength(payload, .v2)); + const header = try frameLength(payload, .v1); + try std.testing.expectEqual(@as(u8, 1), header[3]); +} + +test "v2 hello omits subscription for the no-filter case" { + const allocator = std.testing.allocator; + const hello = try v2Hello(allocator, "00000000-0000-4000-8000-000000000001", null, ""); + defer allocator.free(hello); + try std.testing.expectEqualStrings( + "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2],\"clientID\":\"00000000-0000-4000-8000-000000000001\"}", + hello, + ); + const subscribed = try v2Hello( + allocator, + "00000000-0000-4000-8000-000000000001", + 9, + "C:\\work\\graph", + ); + defer allocator.free(subscribed); + try std.testing.expectEqualStrings( + "{\"version\":2,\"kind\":\"hello\",\"supportedVersions\":[1,2],\"clientID\":\"00000000-0000-4000-8000-000000000001\",\"resumeFrom\":9,\"subscription\":{\"projectPaths\":[\"C:\\\\work\\\\graph\"]}}", + subscribed, + ); +} + +test "graph commands match Swift Codable associated-value shapes" { + const allocator = std.testing.allocator; + const project = "C:\\work\\graph"; + const node = "11111111-1111-4111-8111-111111111111"; + const create = try commandGraphCreateNode( + allocator, + project, + "Windows shell node", + node, + ); + defer allocator.free(create); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"createNode\":{\"_0\":{\"id\":\"11111111-1111-4111-8111-111111111111\",\"title\":\"Windows shell node\",\"loopType\":\"turnBased\",\"checkDescription\":null,\"triggerPrompt\":null,\"firstInstruction\":\"Work on the requested Windows shell task.\",\"pausesBeforeWritesOnly\":false,\"goal\":null,\"backend\":\"claudeCode\",\"modelTier\":null,\"worktree\":null,\"subGraph\":null,\"createdBy\":null}}}}}", + create, + ); + const message = try commandGraphNodeAction(allocator, project, node, "messageNode", "hello"); + defer allocator.free(message); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"messageNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\",\"text\":\"hello\",\"from\":null}}}}", + message, + ); + const stop = try commandGraphNodeAction(allocator, project, node, "stopNode", null); + defer allocator.free(stop); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"stopNode\":{\"_0\":\"11111111-1111-4111-8111-111111111111\"}}}}", + stop, + ); + try std.testing.expectError( + error.UnsupportedGraphAction, + commandGraphNodeAction(allocator, project, node, "deleteNode", null), + ); + const rename = try commandGraphRenameNode(allocator, project, node, "Renamed"); + defer allocator.free(rename); + try std.testing.expect(std.mem.indexOf(u8, rename, "\"renameNode\"") != null); + const edge = try commandGraphCreateEdge(allocator, project, node, "22222222-2222-4222-8222-222222222222", "handoff"); + defer allocator.free(edge); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"createEdge\":{\"from\":\"11111111-1111-4111-8111-111111111111\",\"to\":\"22222222-2222-4222-8222-222222222222\",\"spec\":{\"kind\":\"handoff\",\"condition\":\"always\",\"payloadTransform\":{\"none\":{}},\"cycleGuard\":null,\"spawnTargetProjectPath\":null}}}}}", + edge, + ); + const delete = try commandGraphDeleteEdge(allocator, project, "33333333-3333-4333-8333-333333333333"); + defer allocator.free(delete); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"deleteEdge\":{\"_0\":\"33333333-3333-4333-8333-333333333333\"}}}}", + delete, + ); +} + +test "global overview command uses the daemon command shape" { + const command = try commandOpenGlobalGraph(std.testing.allocator); + defer std.testing.allocator.free(command); + try std.testing.expectEqualStrings("{\"openGlobalGraph\":{}}", command); +} + +test "project lifecycle commands preserve Swift Codable labels" { + const allocator = std.testing.allocator; + const close = try commandCloseProject(allocator, "C:\\work\\graph"); + defer allocator.free(close); + try std.testing.expectEqualStrings("{\"closeProject\":{\"path\":\"C:\\\\work\\\\graph\"}}", close); + const forget = try commandForgetProject(allocator, "C:\\work\\graph"); + defer allocator.free(forget); + try std.testing.expectEqualStrings("{\"forgetProject\":{\"path\":\"C:\\\\work\\\\graph\"}}", forget); + const delete = try commandDeleteProjectGraph(allocator, "C:\\work\\graph"); + defer allocator.free(delete); + try std.testing.expectEqualStrings("{\"deleteProjectGraph\":{\"path\":\"C:\\\\work\\\\graph\"}}", delete); +} + +test "typed node and edge forms retain every supported field on the wire" { + const allocator = std.testing.allocator; + const node = try commandGraphCreateNodeFull(allocator, "C:\\work\\graph", "11111111-1111-4111-8111-111111111111", .{ + .title = "Goal", + .loop_type = "goalBased", + .goal_summary = "Done", + .goal_predicate = "test -f done", + .poll_interval_seconds = 15, + .stall_after_seconds = 0, + .metric_command = "metric", + .metric_direction = "minimize", + .backend = "codex", + .model_tier = "capable", + .worktree_repository = "C:\\repo", + .worktree_id = "wt", + .worktree_path = "C:\\repo-wt", + .worktree_branch = "feature", + .subgraph_json = "{\"id\":\"33333333-3333-4333-8333-333333333333\",\"project\":{\"path\":\"C:\\\\work\\\\subgraph\",\"name\":\"subgraph\",\"lastOpenedAt\":1767225600},\"nodes\":[],\"edges\":[]}", + .created_by = "11111111-1111-4111-8111-111111111111", + }); + defer allocator.free(node); + for ([_][]const u8{ "\"summary\":\"Done\"", "\"predicate\":\"test -f done\"", "pollIntervalSeconds", "stallAfterSeconds", "metricCommand", "metricDirection", "codex", "capable", "repositoryPath", "worktreePath", "feature", "\"nodes\":[]", "\"createdBy\":\"11111111-1111-4111-8111-111111111111\"" }) |field| { + try std.testing.expect(std.mem.indexOf(u8, node, field) != null); + } + const inherited = try commandGraphCreateNodeFull(allocator, "C:\\work\\graph", "11111111-1111-4111-8111-111111111111", .{ + .title = "Inherited", + .backend = null, + }); + defer allocator.free(inherited); + try std.testing.expect(std.mem.indexOf(u8, inherited, "\"backend\":null") != null); + try std.testing.expect(std.mem.indexOf(u8, inherited, "\"createdBy\":null") != null); + const edge = try commandGraphCreateEdgeFull(allocator, "C:\\work\\graph", "a", "b", .{ + .from = "a", + .to = "b", + .kind = "spawn", + .condition = "onFailure", + .transform_kind = "template", + .transform_value = "payload", + .cycle_max_iterations = 3, + .spawn_target_project_path = "C:\\other", + }); + defer allocator.free(edge); + for ([_][]const u8{ "onFailure", "template", "payload", "maxIterations", "spawnTargetProjectPath" }) |field| + try std.testing.expect(std.mem.indexOf(u8, edge, field) != null); +} + +test "quick chat commands match shared Codable labels" { + const allocator = std.testing.allocator; + const list = try commandListQuickChats(allocator); + defer allocator.free(list); + try std.testing.expectEqualStrings("{\"listQuickChats\":{}}", list); + const create = try commandCreateQuickChat(allocator, "Scratch", "claudeCode"); + defer allocator.free(create); + try std.testing.expectEqualStrings( + "{\"createQuickChat\":{\"title\":\"Scratch\",\"backend\":\"claudeCode\"}}", + create, + ); + const open = try commandOpenQuickChat(allocator, "11111111-1111-4111-8111-111111111111"); + defer allocator.free(open); + try std.testing.expectEqualStrings( + "{\"openQuickChat\":{\"id\":\"11111111-1111-4111-8111-111111111111\"}}", + open, + ); + const rename = try commandRenameQuickChat(allocator, "11111111-1111-4111-8111-111111111111", "Renamed"); + defer allocator.free(rename); + try std.testing.expect(std.mem.indexOf(u8, rename, "\"renameQuickChat\"") != null); + const delete = try commandDeleteQuickChat(allocator, "11111111-1111-4111-8111-111111111111"); + defer allocator.free(delete); + try std.testing.expect(std.mem.indexOf(u8, delete, "\"deleteQuickChat\"") != null); +} + +test "v2 edge fixtures exactly match Zig-generated request envelopes" { + const allocator = std.testing.allocator; + const create_inner = try commandGraphCreateEdge( + allocator, + "C:\\work\\graph", + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "handoff", + ); + defer allocator.free(create_inner); + const create = try v2Request( + allocator, + "00000000-0000-4000-8000-000000000005", + create_inner, + ); + defer allocator.free(create); + const expected_create = try std.fs.cwd().readFileAlloc( + allocator, + "fixtures/daemon-v2-create-edge.json", + 16 * 1024, + ); + defer allocator.free(expected_create); + try std.testing.expectEqualStrings(expected_create, create); + + const delete_inner = try commandGraphDeleteEdge( + allocator, + "C:\\work\\graph", + "33333333-3333-4333-8333-333333333333", + ); + defer allocator.free(delete_inner); + const delete = try v2Request( + allocator, + "00000000-0000-4000-8000-000000000006", + delete_inner, + ); + defer allocator.free(delete); + const expected_delete = try std.fs.cwd().readFileAlloc( + allocator, + "fixtures/daemon-v2-delete-edge.json", + 16 * 1024, + ); + defer allocator.free(expected_delete); + try std.testing.expectEqualStrings(expected_delete, delete); +} + +test "graph commands can be addressed into a composite subgraph" { + const command = try commandGraphDeleteNode( + std.testing.allocator, + "C:\\work\\graph", + "child", + ); + defer std.testing.allocator.free(command); + const addressed = try addressGraphCommandToSubGraph( + std.testing.allocator, + command, + "parent", + ); + defer std.testing.allocator.free(addressed); + try std.testing.expectEqualStrings( + "{\"graphCommand\":{\"projectPath\":\"C:\\\\work\\\\graph\",\"command\":{\"subGraphCommand\":{\"nodeID\":\"parent\",\"command\":{\"deleteNode\":{\"_0\":\"child\"}}}}}}", + addressed, + ); +} diff --git a/graphcode-windows/src/WorkspaceControls.zig b/graphcode-windows/src/WorkspaceControls.zig new file mode 100644 index 00000000..7d46457c --- /dev/null +++ b/graphcode-windows/src/WorkspaceControls.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub const Action = enum { + show_graph, + toggle_rail, + toggle_panel, + toggle_activity, + increase_activity_limit, + decrease_activity_limit, +}; + +pub const State = struct { + graph_visible: bool = true, + rail_visible: bool = true, + panel_visible: bool = true, + activity_enabled: bool = true, + activity_limit: usize = 32, + + pub fn apply(self: *State, action: Action) void { + switch (action) { + .show_graph => self.graph_visible = true, + .toggle_rail => self.rail_visible = !self.rail_visible, + .toggle_panel => self.panel_visible = !self.panel_visible, + .toggle_activity => self.activity_enabled = !self.activity_enabled, + .increase_activity_limit => self.activity_limit = @min(self.activity_limit + 8, 256), + .decrease_activity_limit => self.activity_limit = @max(self.activity_limit -| 8, 8), + } + } +}; + +test "workspace controls keep graph rail panel and activity state independent" { + var state = State{}; + state.apply(.toggle_rail); + state.apply(.toggle_panel); + state.apply(.toggle_activity); + try std.testing.expect(!state.rail_visible); + try std.testing.expect(!state.panel_visible); + try std.testing.expect(!state.activity_enabled); + try std.testing.expect(state.graph_visible); + state.apply(.decrease_activity_limit); + try std.testing.expectEqual(@as(usize, 24), state.activity_limit); +} diff --git a/graphcode-windows/src/WorkspaceLayout.zig b/graphcode-windows/src/WorkspaceLayout.zig new file mode 100644 index 00000000..8f6f0608 --- /dev/null +++ b/graphcode-windows/src/WorkspaceLayout.zig @@ -0,0 +1,507 @@ +const std = @import("std"); + +pub const schema_version: i64 = 2; +pub const Direction = enum { horizontal, vertical }; + +pub const Pane = struct { + id: []u8, + launches_agent: bool = false, +}; + +pub const ClosedPane = struct { + id: []u8, + launches_agent: bool, + tab_id: u64, + tab_index: usize, + pane_index: usize, + split_direction: Direction, + focused_pane: usize, + selected_tab: usize, + + pub fn deinit(self: *ClosedPane, allocator: std.mem.Allocator) void { + allocator.free(self.id); + } +}; + +pub const Tab = struct { + id: u64, + panes: std.ArrayListUnmanaged(Pane), + split_direction: Direction = .horizontal, + focused_pane: usize = 0, + + fn deinit(self: *Tab, allocator: std.mem.Allocator) void { + for (self.panes.items) |pane| allocator.free(pane.id); + self.panes.deinit(allocator); + } +}; + +pub const Layout = struct { + allocator: std.mem.Allocator, + project_key: []u8, + tabs: std.ArrayListUnmanaged(Tab) = .empty, + selected_tab: usize = 0, + next_tab_id: u64 = 1, + + pub fn init(allocator: std.mem.Allocator, project_key: []const u8) !Layout { + return .{ + .allocator = allocator, + .project_key = try allocator.dupe(u8, project_key), + }; + } + + pub fn deinit(self: *Layout) void { + for (self.tabs.items) |*tab| tab.deinit(self.allocator); + self.tabs.deinit(self.allocator); + self.allocator.free(self.project_key); + } + + pub fn default(allocator: std.mem.Allocator, project_key: []const u8, node_id: []const u8) !Layout { + var layout = try Layout.init(allocator, project_key); + errdefer layout.deinit(); + try layout.addTab(node_id, true); + return layout; + } + + pub fn addTab(self: *Layout, surface_id: []const u8, launches_agent: bool) !void { + try self.validateNewID(surface_id); + var panes: std.ArrayListUnmanaged(Pane) = .empty; + errdefer panes.deinit(self.allocator); + try panes.append(self.allocator, .{ + .id = try self.allocator.dupe(u8, surface_id), + .launches_agent = launches_agent, + }); + try self.tabs.append(self.allocator, .{ .id = self.next_tab_id, .panes = panes }); + self.next_tab_id += 1; + self.selected_tab = self.tabs.items.len - 1; + } + + pub fn newSurfaceID(self: *Layout) ![]u8 { + var bytes: [16]u8 = undefined; + var output: [36]u8 = undefined; + while (true) { + std.crypto.random.bytes(&bytes); + var output_index: usize = 0; + for (bytes) |byte| { + while (output_index == 8 or output_index == 13 or output_index == 18 or output_index == 23) + output_index += 1; + const hex = "0123456789abcdef"; + output[output_index] = hex[byte >> 4]; + output[output_index + 1] = hex[byte & 0x0f]; + output_index += 2; + } + output[8] = '-'; + output[13] = '-'; + output[18] = '-'; + output[23] = '-'; + if (!self.idExists(output[0..])) { + if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_SESSION_PREFIX")) |prefix| { + defer self.allocator.free(prefix); + return std.fmt.allocPrint( + self.allocator, + "{s}-{s}", + .{ prefix, output[0..] }, + ); + } else |_| { + return self.allocator.dupe(u8, output[0..]); + } + } + } + } + + pub fn selected(self: *Layout) ?*Tab { + if (self.selected_tab >= self.tabs.items.len) return null; + return &self.tabs.items[self.selected_tab]; + } + + pub fn selectedConst(self: *const Layout) ?*const Tab { + if (self.selected_tab >= self.tabs.items.len) return null; + return &self.tabs.items[self.selected_tab]; + } + + pub fn selectTab(self: *Layout, index: usize) !void { + if (index >= self.tabs.items.len) return error.InvalidTab; + self.selected_tab = index; + } + + pub fn selectRelativeTab(self: *Layout, offset: isize) void { + if (self.tabs.items.len == 0) return; + const count: isize = @intCast(self.tabs.items.len); + const current: isize = @intCast(self.selected_tab); + self.selected_tab = @intCast(@mod(current + offset, count)); + } + + pub fn splitFocused(self: *Layout, direction: Direction, surface_id: []const u8) !void { + const tab = self.selected() orelse return error.NoTabs; + try self.validateNewID(surface_id); + if (tab.focused_pane >= tab.panes.items.len) return error.InvalidFocus; + tab.split_direction = direction; + try tab.panes.insert(self.allocator, tab.focused_pane + 1, .{ + .id = try self.allocator.dupe(u8, surface_id), + }); + tab.focused_pane += 1; + } + + pub fn closeFocusedPane(self: *Layout) !ClosedPane { + const tab = self.selected() orelse return error.NoTabs; + if (tab.panes.items.len == 0 or tab.focused_pane >= tab.panes.items.len) + return error.InvalidTopology; + const tab_index = self.selected_tab; + const pane_index = tab.focused_pane; + const removed = tab.panes.orderedRemove(tab.focused_pane); + const id = removed.id; + const record = ClosedPane{ + .id = id, + .launches_agent = removed.launches_agent, + .tab_id = tab.id, + .tab_index = tab_index, + .pane_index = pane_index, + .split_direction = tab.split_direction, + .focused_pane = tab.focused_pane, + .selected_tab = self.selected_tab, + }; + if (tab.panes.items.len == 0) { + var closed = self.tabs.orderedRemove(self.selected_tab); + closed.deinit(self.allocator); + if (self.selected_tab >= self.tabs.items.len and self.tabs.items.len != 0) + self.selected_tab = self.tabs.items.len - 1; + } else if (tab.focused_pane >= tab.panes.items.len) { + tab.focused_pane = tab.panes.items.len - 1; + } + return record; + } + + pub fn restoreClosedPane(self: *Layout, record: *const ClosedPane) !void { + if (self.idExists(record.id)) return error.DuplicateSurfaceID; + for (self.tabs.items) |*tab| { + if (tab.id != record.tab_id) continue; + try tab.panes.insert(self.allocator, record.pane_index, .{ + .id = try self.allocator.dupe(u8, record.id), + .launches_agent = record.launches_agent, + }); + tab.split_direction = record.split_direction; + tab.focused_pane = @min(record.focused_pane, tab.panes.items.len - 1); + self.selected_tab = @min(record.selected_tab, self.tabs.items.len - 1); + return; + } + var panes: std.ArrayListUnmanaged(Pane) = .empty; + errdefer panes.deinit(self.allocator); + try panes.append(self.allocator, .{ + .id = try self.allocator.dupe(u8, record.id), + .launches_agent = record.launches_agent, + }); + try self.tabs.insert(self.allocator, @min(record.tab_index, self.tabs.items.len), .{ + .id = record.tab_id, + .panes = panes, + .split_direction = record.split_direction, + .focused_pane = 0, + }); + self.selected_tab = @min(record.selected_tab, self.tabs.items.len - 1); + } + + pub fn replacePaneID(self: *Layout, old_id: []const u8, new_id: []const u8) !void { + if (std.mem.eql(u8, old_id, new_id)) return; + if (new_id.len == 0 or new_id.len > 128 or self.idExists(new_id)) return error.DuplicateSurfaceID; + for (self.tabs.items) |*tab| { + for (tab.panes.items) |*pane| { + if (std.mem.eql(u8, pane.id, old_id)) { + const replacement = try self.allocator.dupe(u8, new_id); + self.allocator.free(pane.id); + pane.id = replacement; + return; + } + } + } + return error.InvalidSurface; + } + + pub fn removePane(self: *Layout, id: []const u8) bool { + for (self.tabs.items, 0..) |*tab, tab_index| { + for (tab.panes.items, 0..) |pane, pane_index| { + if (!std.mem.eql(u8, pane.id, id)) continue; + self.allocator.free(pane.id); + _ = tab.panes.orderedRemove(pane_index); + if (tab.panes.items.len == 0) { + var removed_tab = self.tabs.orderedRemove(tab_index); + removed_tab.deinit(self.allocator); + if (self.selected_tab >= self.tabs.items.len and self.tabs.items.len != 0) + self.selected_tab = self.tabs.items.len - 1; + } else if (tab.focused_pane >= tab.panes.items.len) { + tab.focused_pane = tab.panes.items.len - 1; + } + return true; + } + } + return false; + } + + pub fn focusPane(self: *Layout, offset: isize) !void { + const tab = self.selected() orelse return error.NoTabs; + if (tab.panes.items.len == 0 or tab.focused_pane >= tab.panes.items.len) + return error.InvalidFocus; + const count: isize = @intCast(tab.panes.items.len); + tab.focused_pane = @intCast(@mod(@as(isize, @intCast(tab.focused_pane)) + offset, count)); + } + + pub fn save(self: *const Layout, file_path: []const u8) !void { + if (self.tabs.items.len != 0 and self.selected_tab >= self.tabs.items.len) + return error.InvalidTopology; + if (self.tabs.items.len != 0) try self.validateTopology(); + const tmp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp", .{file_path}); + defer self.allocator.free(tmp_path); + var file = try std.fs.cwd().createFile(tmp_path, .{ .truncate = true }); + defer file.close(); + var buffer: [4096]u8 = undefined; + var writer = file.writer(&buffer); + try writer.interface.writeAll("{\"schemaVersion\":2,\"project\":"); + try writer.interface.print("{f}", .{std.json.fmt(self.project_key, .{})}); + try writer.interface.print(",\"selectedTab\":{d},\"tabs\":[", .{self.selected_tab}); + for (self.tabs.items, 0..) |tab, tab_index| { + if (tab_index != 0) try writer.interface.writeByte(','); + try writer.interface.print( + "{{\"id\":{d},\"direction\":\"{s}\",\"focused\":{d},\"panes\":[", + .{ tab.id, @tagName(tab.split_direction), tab.focused_pane }, + ); + for (tab.panes.items, 0..) |pane, pane_index| { + if (pane_index != 0) try writer.interface.writeByte(','); + try writer.interface.print( + "{{\"id\":{f},\"agent\":{s}}}", + .{ std.json.fmt(pane.id, .{}), if (pane.launches_agent) "true" else "false" }, + ); + } + try writer.interface.writeAll("]}"); + } + try writer.interface.writeAll("]}"); + try writer.interface.flush(); + try std.fs.cwd().rename(tmp_path, file_path); + } + + pub fn load( + allocator: std.mem.Allocator, + file_path: []const u8, + expected_project: []const u8, + ) !Layout { + const data = try std.fs.cwd().readFileAlloc(allocator, file_path, 4 * 1024 * 1024); + defer allocator.free(data); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const root = try object(parsed.value); + const version = try integer(try field(root, "schemaVersion")); + if (version != schema_version) return error.UnsupportedSchema; + const project = try string(try field(root, "project")); + if (!std.mem.eql(u8, project, expected_project)) return error.ProjectMismatch; + const selected_index = try nonNegativeIndex(try field(root, "selectedTab")); + const values = try array(try field(root, "tabs")); + var layout = try Layout.init(allocator, expected_project); + errdefer layout.deinit(); + layout.selected_tab = selected_index; + for (values) |encoded| { + const tab_object = try object(encoded); + const tab_id = try positiveU64(try field(tab_object, "id")); + const direction_name = try string(try field(tab_object, "direction")); + const direction = if (std.mem.eql(u8, direction_name, "horizontal")) + Direction.horizontal + else if (std.mem.eql(u8, direction_name, "vertical")) + Direction.vertical + else + return error.InvalidDirection; + const focused = try nonNegativeIndex(try field(tab_object, "focused")); + const pane_values = try array(try field(tab_object, "panes")); + if (pane_values.len == 0 or focused >= pane_values.len) return error.InvalidTopology; + var panes: std.ArrayListUnmanaged(Pane) = .empty; + errdefer { + for (panes.items) |pane| allocator.free(pane.id); + panes.deinit(allocator); + } + for (pane_values) |encoded_pane| { + const pane_object = try object(encoded_pane); + const id = try string(try field(pane_object, "id")); + if (id.len == 0 or id.len > 128 or layout.idExists(id)) return error.DuplicateSurfaceID; + for (panes.items) |existing| { + if (std.mem.eql(u8, existing.id, id)) return error.DuplicateSurfaceID; + } + const agent = try boolean(try field(pane_object, "agent")); + try panes.append(allocator, .{ + .id = try allocator.dupe(u8, id), + .launches_agent = agent, + }); + } + try layout.tabs.append(allocator, .{ + .id = tab_id, + .panes = panes, + .split_direction = direction, + .focused_pane = focused, + }); + } + if (layout.selected_tab >= layout.tabs.items.len and layout.tabs.items.len != 0) + return error.InvalidTopology; + if (layout.tabs.items.len != 0) try layout.validateTopology(); + layout.next_tab_id = 1; + for (layout.tabs.items) |tab| layout.next_tab_id = @max(layout.next_tab_id, tab.id + 1); + return layout; + } + + fn validateNewID(self: *const Layout, id: []const u8) !void { + if (id.len == 0 or id.len > 128 or self.idExists(id)) return error.DuplicateSurfaceID; + } + + fn idExists(self: *const Layout, id: []const u8) bool { + for (self.tabs.items) |tab| for (tab.panes.items) |pane| { + if (std.mem.eql(u8, pane.id, id)) return true; + }; + return false; + } + + fn validateTopology(self: *const Layout) !void { + if (self.tabs.items.len == 0 or self.selected_tab >= self.tabs.items.len) + return error.InvalidTopology; + var ids = std.StringHashMap(void).init(self.allocator); + defer ids.deinit(); + for (self.tabs.items) |tab| { + if (tab.id == 0 or tab.panes.items.len == 0 or tab.focused_pane >= tab.panes.items.len) + return error.InvalidTopology; + for (tab.panes.items) |pane| { + if (pane.id.len == 0 or pane.id.len > 128 or ids.contains(pane.id)) + return error.InvalidTopology; + try ids.put(pane.id, {}); + } + } + } +}; + +fn field(object_value: std.json.ObjectMap, name: []const u8) !std.json.Value { + return object_value.get(name) orelse error.MissingField; +} + +fn object(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |value_object| value_object, + else => error.ExpectedObject, + }; +} + +fn array(value: std.json.Value) ![]const std.json.Value { + return switch (value) { + .array => |value_array| value_array.items, + else => error.ExpectedArray, + }; +} + +fn string(value: std.json.Value) ![]const u8 { + return switch (value) { + .string => |value_string| value_string, + else => error.ExpectedString, + }; +} + +fn boolean(value: std.json.Value) !bool { + return switch (value) { + .bool => |value_bool| value_bool, + else => error.ExpectedBoolean, + }; +} + +fn integer(value: std.json.Value) !i64 { + return switch (value) { + .integer => |value_integer| value_integer, + else => error.ExpectedInteger, + }; +} + +fn nonNegativeIndex(value: std.json.Value) !usize { + const number = try integer(value); + if (number < 0) return error.NegativeIndex; + return std.math.cast(usize, number) orelse error.IndexOverflow; +} + +fn positiveU64(value: std.json.Value) !u64 { + const number = try integer(value); + if (number <= 0) return error.InvalidIdentifier; + return std.math.cast(u64, number) orelse error.IdentifierOverflow; +} + +test "validated persistence rejects corruption and scopes projects" { + var layout = try Layout.default(std.testing.allocator, "project-a", "node-a"); + defer layout.deinit(); + try layout.save("workspace-layout-test.json"); + defer std.fs.cwd().deleteFile("workspace-layout-test.json") catch {}; + var restored = try Layout.load(std.testing.allocator, "workspace-layout-test.json", "project-a"); + restored.deinit(); + try std.testing.expectError(error.ProjectMismatch, Layout.load( + std.testing.allocator, + "workspace-layout-test.json", + "project-b", + )); +} + +test "generated surface IDs are unique across tabs" { + var layout = try Layout.init(std.testing.allocator, "project"); + defer layout.deinit(); + const first = try layout.newSurfaceID(); + defer std.testing.allocator.free(first); + const second = try layout.newSurfaceID(); + defer std.testing.allocator.free(second); + try std.testing.expect(!std.mem.eql(u8, first, second)); +} + +test "validated persistence rejects malformed topology" { + const cases = [_]struct { + json: []const u8, + expected: anyerror, + }{ + .{ .json = "{\"schemaVersion\":2,\"project\":\"p\",\"tabs\":[]}", .expected = error.MissingField }, + .{ .json = "{\"schemaVersion\":2,\"project\":\"p\",\"selectedTab\":-1,\"tabs\":[]}", .expected = error.NegativeIndex }, + .{ .json = "{\"schemaVersion\":2,\"project\":\"p\",\"selectedTab\":0,\"tabs\":[{\"id\":1,\"direction\":\"diagonal\",\"focused\":0,\"panes\":[{\"id\":\"a\",\"agent\":false}]}]}", .expected = error.InvalidDirection }, + .{ .json = "{\"schemaVersion\":2,\"project\":\"p\",\"selectedTab\":0,\"tabs\":[{\"id\":1,\"direction\":\"horizontal\",\"focused\":0,\"panes\":[{\"id\":\"a\",\"agent\":false},{\"id\":\"a\",\"agent\":false}]}]}", .expected = error.DuplicateSurfaceID }, + }; + for (cases, 0..) |case, index| { + const path = try std.fmt.allocPrint(std.testing.allocator, "workspace-corrupt-{d}.json", .{index}); + defer std.testing.allocator.free(path); + defer std.fs.cwd().deleteFile(path) catch {}; + try std.fs.cwd().writeFile(.{ .sub_path = path, .data = case.json }); + try std.testing.expectError(case.expected, Layout.load(std.testing.allocator, path, "p")); + } +} + +test "close rollback restores exact tab and pane topology" { + var layout = try Layout.default(std.testing.allocator, "p", "first"); + defer layout.deinit(); + try layout.splitFocused(.vertical, "second"); + layout.selected_tab = 0; + layout.tabs.items[0].focused_pane = 1; + layout.tabs.items[0].panes.items[1].launches_agent = true; + const before_direction = layout.tabs.items[0].split_direction; + var record = try layout.closeFocusedPane(); + defer record.deinit(std.testing.allocator); + try layout.restoreClosedPane(&record); + try std.testing.expectEqual(@as(u64, 1), layout.tabs.items[0].id); + try std.testing.expectEqual(before_direction, layout.tabs.items[0].split_direction); + try std.testing.expectEqual(@as(usize, 2), layout.tabs.items[0].panes.items.len); + try std.testing.expectEqualStrings("second", layout.tabs.items[0].panes.items[1].id); + try std.testing.expect(layout.tabs.items[0].panes.items[1].launches_agent); + try std.testing.expectEqual(@as(usize, 1), layout.tabs.items[0].focused_pane); +} + +test "topology mutation rollback leaves no phantom tab or split" { + var layout = try Layout.default(std.testing.allocator, "p", "first"); + defer layout.deinit(); + const selected = layout.selected_tab; + const next_id = layout.next_tab_id; + try layout.addTab("second", false); + try std.testing.expectEqual(@as(usize, 2), layout.tabs.items.len); + _ = layout.removePane("second"); + layout.selected_tab = selected; + layout.next_tab_id = next_id; + try std.testing.expectEqual(@as(usize, 1), layout.tabs.items.len); + const tab = layout.selected().?; + const focus = tab.focused_pane; + const direction = tab.split_direction; + try layout.splitFocused(.vertical, "split"); + _ = layout.removePane("split"); + if (layout.selected()) |restored| { + restored.focused_pane = focus; + restored.split_direction = direction; + } + try std.testing.expectEqual(@as(usize, 1), layout.selected().?.panes.items.len); + try std.testing.expectEqual(focus, layout.selected().?.focused_pane); + try std.testing.expectEqual(direction, layout.selected().?.split_direction); +} diff --git a/graphcode-windows/src/WorktreeDialog.zig b/graphcode-windows/src/WorktreeDialog.zig new file mode 100644 index 00000000..7096150a --- /dev/null +++ b/graphcode-windows/src/WorktreeDialog.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const WorktreeStatus = @import("WorktreeStatus.zig"); + +pub const Row = struct { + entry: WorktreeStatus.Entry, + selected: bool = false, +}; + +pub const ReclaimError = error{ + ConfirmationRequired, + PolicyDisabled, + UnsafeSelection, +}; + +pub const Dialog = struct { + allocator: std.mem.Allocator, + project_path: []u8, + policy: WorktreeStatus.Policy, + rows: std.array_list.Managed(Row), + confirmation_armed: bool = false, + + pub fn init( + allocator: std.mem.Allocator, + project_path: []const u8, + entries: []const WorktreeStatus.Entry, + policy: WorktreeStatus.Policy, + ) !Dialog { + var dialog = Dialog{ + .allocator = allocator, + .project_path = try allocator.dupe(u8, project_path), + .policy = policy, + .rows = std.array_list.Managed(Row).init(allocator), + }; + errdefer dialog.deinit(); + for (entries) |entry| try dialog.rows.append(.{ .entry = entry }); + return dialog; + } + + pub fn deinit(self: *Dialog) void { + self.allocator.free(self.project_path); + self.rows.deinit(); + } + + pub fn toggle(self: *Dialog, index: usize) bool { + if (index >= self.rows.items.len) return false; + self.rows.items[index].selected = !self.rows.items[index].selected; + self.confirmation_armed = false; + return true; + } + + pub fn clearSelection(self: *Dialog) void { + for (self.rows.items) |*row| row.selected = false; + self.confirmation_armed = false; + } + + pub fn setPolicy(self: *Dialog, policy: WorktreeStatus.Policy) void { + self.policy = policy; + self.confirmation_armed = false; + } + + pub fn savePolicy(self: *const Dialog) !void { + try WorktreeStatus.savePolicy(self.allocator, self.project_path, self.policy); + } + + pub fn selectedCount(self: *const Dialog) usize { + var count: usize = 0; + for (self.rows.items) |row| { + if (row.selected) count += 1; + } + return count; + } + + pub fn selectedPaths(self: *const Dialog, allocator: std.mem.Allocator) !std.array_list.Managed([]const u8) { + var result = std.array_list.Managed([]const u8).init(allocator); + errdefer result.deinit(); + for (self.rows.items) |row| if (row.selected) try result.append(row.entry.path); + return result; + } + + pub fn armConfirmation(self: *Dialog) ReclaimError!void { + if (!self.policy.allow_reclaim) return error.PolicyDisabled; + if (self.selectedCount() == 0) return error.UnsafeSelection; + for (self.rows.items) |row| { + if (row.selected and WorktreeStatus.decision(row.entry) != .reclaimable) + return error.UnsafeSelection; + } + self.confirmation_armed = true; + } + + pub fn canConfirm(self: *const Dialog) bool { + return self.confirmation_armed and self.policy.allow_reclaim; + } + + pub fn consumeConfirmation(self: *Dialog) ReclaimError!void { + if (!self.canConfirm()) return error.ConfirmationRequired; + self.confirmation_armed = false; + } + + pub fn revealSelected(self: *const Dialog) !WorktreeStatus.ExplorerArgs { + for (self.rows.items) |row| if (row.selected) return WorktreeStatus.explorerArgs(row.entry.path); + return error.EmptyProjectPath; + } +}; + +test "multi-select requires explicit confirmation and fails closed" { + var entries = [_]WorktreeStatus.Entry{ + .{ .path = @constCast("C:\\safe ☃"), .branch = @constCast("safe"), .pushed = true, .landed = true }, + .{ .path = @constCast("C:\\dirty"), .branch = @constCast("dirty"), .dirty = true, .pushed = true, .landed = true }, + }; + var dialog = try Dialog.init(std.testing.allocator, "C:\\project", &entries, .{}); + defer dialog.deinit(); + try std.testing.expect(dialog.toggle(0)); + try std.testing.expectError(error.PolicyDisabled, dialog.armConfirmation()); + dialog.policy.allow_reclaim = true; + try std.testing.expect(dialog.toggle(1)); + try std.testing.expectError(error.UnsafeSelection, dialog.armConfirmation()); +} + +test "reveal preserves Unicode path and uses Explorer verb" { + var entries = [_]WorktreeStatus.Entry{ + .{ .path = @constCast("C:\\工作\\review"), .branch = @constCast("review"), .pushed = true, .landed = true }, + }; + var dialog = try Dialog.init(std.testing.allocator, "C:\\project", &entries, .{ .allow_reclaim = true }); + defer dialog.deinit(); + _ = dialog.toggle(0); + const args = try dialog.revealSelected(); + try std.testing.expectEqualStrings("explore", args.verb); + try std.testing.expectEqualStrings("C:\\工作\\review", args.path); +} + +test "confirmed multi-select is consumable exactly once" { + var entries = [_]WorktreeStatus.Entry{ + .{ .path = @constCast("C:\\one"), .branch = @constCast("one"), .pushed = true, .landed = true }, + .{ .path = @constCast("C:\\two"), .branch = @constCast("two"), .pushed = true, .landed = true }, + }; + var dialog = try Dialog.init(std.testing.allocator, "C:\\project", &entries, .{ .allow_reclaim = true }); + defer dialog.deinit(); + _ = dialog.toggle(0); + _ = dialog.toggle(1); + try dialog.armConfirmation(); + try dialog.consumeConfirmation(); + try std.testing.expectError(error.ConfirmationRequired, dialog.consumeConfirmation()); +} diff --git a/graphcode-windows/src/WorktreeStatus.zig b/graphcode-windows/src/WorktreeStatus.zig new file mode 100644 index 00000000..b9290869 --- /dev/null +++ b/graphcode-windows/src/WorktreeStatus.zig @@ -0,0 +1,658 @@ +const std = @import("std"); + +pub const Entry = struct { + path: []u8, + branch: []u8, + primary: bool = false, + locked: bool = false, + prunable: bool = false, + dirty: bool = false, + untracked: bool = false, + conflicted: bool = false, + pushed: bool = false, + landed: bool = false, + bound_running: bool = false, +}; + +pub fn explorerParameters(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + if (path.len == 0) return error.EmptyProjectPath; + return std.fmt.allocPrint(allocator, "/select,\"{s}\"", .{path}); +} + +pub const FailureReason = enum { + primary, locked, prunable, dirty, untracked, conflicted, + unpushed, not_landed, bound_running, safe, +}; + +pub const ResolveAction = enum { legacy, remove, ask, keep }; + +pub const Policy = struct { + /// Reclaim is opt-in; missing or malformed policy stays disabled. + allow_reclaim: bool = false, + confirm_each_reclaim: bool = true, + resolve_action: ResolveAction = .legacy, + notice_size_gb: u32 = 2, + notice_count: u32 = 8, + + pub fn effectiveResolveAction(self: Policy) ResolveAction { + if (self.resolve_action != .legacy) return self.resolve_action; + if (!self.allow_reclaim) return .keep; + return if (self.confirm_each_reclaim) .ask else .remove; + } + + pub fn applyResolveAction(self: *Policy, action: ResolveAction) void { + self.resolve_action = action; + self.allow_reclaim = action != .keep; + self.confirm_each_reclaim = action == .ask; + } +}; + +pub const PolicyParseError = error{MalformedPolicy}; + +pub fn failureReason(entry: Entry) FailureReason { + if (entry.primary) return .primary; + if (entry.locked) return .locked; + if (entry.prunable) return .prunable; + if (entry.dirty) return .dirty; + if (entry.untracked) return .untracked; + if (entry.conflicted) return .conflicted; + if (!entry.pushed) return .unpushed; + if (!entry.landed) return .not_landed; + if (entry.bound_running) return .bound_running; + return .safe; +} + +pub fn failureReasonText(entry: Entry) []const u8 { + return switch (failureReason(entry)) { + .primary => "primary checkout", + .locked => "locked", + .prunable => "prunable/stale", + .dirty => "local changes", + .untracked => "untracked files", + .conflicted => "merge conflicts", + .unpushed => "unpushed commits", + .not_landed => "not landed on default", + .bound_running => "bound to active loop", + .safe => "safe to reclaim", + }; +} + +pub fn policyPath(allocator: std.mem.Allocator, project_path: []const u8) ![]u8 { + if (project_path.len == 0) return error.EmptyProjectPath; + return std.fmt.allocPrint(allocator, "{s}\\.graphcode\\worktree-policy.json", .{project_path}); +} + +pub fn encodePolicy(allocator: std.mem.Allocator, policy: Policy) ![]u8 { + const action = policy.effectiveResolveAction(); + return std.fmt.allocPrint( + allocator, + "{{\"allowReclaim\":{s},\"confirmEachReclaim\":{s},\"onResolveLanded\":\"{s}\",\"noticeSizeGB\":{d},\"noticeCount\":{d}}}", + .{ + if (policy.allow_reclaim) "true" else "false", + if (policy.confirm_each_reclaim) "true" else "false", + @tagName(action), + policy.notice_size_gb, + policy.notice_count, + }, + ); +} + +pub fn decodePolicy(bytes: []const u8) PolicyParseError!Policy { + var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, bytes, .{}) catch return error.MalformedPolicy; + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |value| value, + else => return error.MalformedPolicy, + }; + if (object.count() != 2 and object.count() != 5) return error.MalformedPolicy; + const allow_value = object.get("allowReclaim") orelse return error.MalformedPolicy; + const confirm_value = object.get("confirmEachReclaim") orelse return error.MalformedPolicy; + const allow_reclaim = switch (allow_value) { + .bool => |value| value, + else => return error.MalformedPolicy, + }; + const confirm_each_reclaim = switch (confirm_value) { + .bool => |value| value, + else => return error.MalformedPolicy, + }; + var policy = Policy{ .allow_reclaim = allow_reclaim, .confirm_each_reclaim = confirm_each_reclaim }; + if (object.count() == 5) { + const action_value = object.get("onResolveLanded") orelse return error.MalformedPolicy; + const action_text = switch (action_value) { + .string => |value| value, + else => return error.MalformedPolicy, + }; + policy.resolve_action = std.meta.stringToEnum(ResolveAction, action_text) orelse return error.MalformedPolicy; + if (policy.resolve_action == .legacy) return error.MalformedPolicy; + const size_value = object.get("noticeSizeGB") orelse return error.MalformedPolicy; + const count_value = object.get("noticeCount") orelse return error.MalformedPolicy; + policy.notice_size_gb = switch (size_value) { + .integer => |value| if (value > 0 and value <= std.math.maxInt(u32)) @intCast(value) else return error.MalformedPolicy, + else => return error.MalformedPolicy, + }; + policy.notice_count = switch (count_value) { + .integer => |value| if (value > 0 and value <= std.math.maxInt(u32)) @intCast(value) else return error.MalformedPolicy, + else => return error.MalformedPolicy, + }; + policy.applyResolveAction(policy.resolve_action); + } + return policy; +} + +pub fn loadPolicy(allocator: std.mem.Allocator, project_path: []const u8) Policy { + const path = policyPath(allocator, project_path) catch return .{}; + defer allocator.free(path); + const bytes = std.fs.cwd().readFileAlloc(allocator, path, 4096) catch return .{}; + defer allocator.free(bytes); + return decodePolicy(bytes) catch .{}; +} + +pub fn savePolicy(allocator: std.mem.Allocator, project_path: []const u8, policy: Policy) !void { + const path = try policyPath(allocator, project_path); + defer allocator.free(path); + const directory = std.fmt.allocPrint(allocator, "{s}\\.graphcode", .{project_path}) catch return error.OutOfMemory; + defer allocator.free(directory); + try std.fs.cwd().makePath(directory); + const bytes = try encodePolicy(allocator, policy); + defer allocator.free(bytes); + var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(bytes); +} + +pub const Summary = struct { + total: usize = 0, + reclaimable: usize = 0, + blocked: usize = 0, +}; + +pub const InspectionError = error{ + EmptyProjectPath, + GitFailed, + MalformedStatus, +}; + +pub const Inspection = struct { + entries: std.array_list.Managed(Entry), + default_branch: []u8, + project_path: []u8, +}; + +pub const Binding = struct { + path: []const u8, +}; + +pub const ReclaimDecision = enum { reclaimable, keep }; + +pub fn decision(entry: Entry) ReclaimDecision { + if (entry.primary or entry.locked or entry.prunable or entry.dirty or entry.untracked or + entry.conflicted or !entry.pushed or !entry.landed or entry.bound_running) + { + return .keep; + } + return .reclaimable; +} + +pub fn canReclaim(entry: Entry, policy: Policy, confirmed: bool) bool { + return policy.allow_reclaim and (!policy.confirm_each_reclaim or confirmed) and + decision(entry) == .reclaimable; +} + +pub const ExplorerArgs = struct { + executable: []const u8 = "explorer.exe", + verb: []const u8, + path: []const u8, +}; + +pub fn explorerArgs(path: []const u8) !ExplorerArgs { + if (path.len == 0) return error.EmptyProjectPath; + return .{ .verb = "explore", .path = path }; +} + +pub fn explorerCommandLine(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + if (path.len == 0) return error.EmptyProjectPath; + return std.fmt.allocPrint(allocator, "explorer.exe /select,\"{s}\"", .{path}); +} + +pub fn selectedEntry(entries: []const Entry, path: []const u8) ?Entry { + for (entries) |entry| { + if (std.mem.eql(u8, entry.path, path)) return entry; + } + return null; +} + +pub fn inspect( + allocator: std.mem.Allocator, + project_path: []const u8, + bindings: []const Binding, +) !Inspection { + if (project_path.len == 0) return error.EmptyProjectPath; + const list = try runGit(allocator, &.{ + "git", "-C", project_path, "worktree", "list", "--porcelain", + }); + defer allocator.free(list.output); + var entries = try parse(allocator, list.output); + errdefer deinit(allocator, &entries); + const default_branch = try discoverDefault(allocator, project_path, entries.items); + errdefer allocator.free(default_branch); + for (entries.items, 0..) |*entry, index| { + entry.primary = index == 0; + for (bindings) |binding| { + if (std.mem.eql(u8, entry.path, binding.path)) { + entry.bound_running = true; + break; + } + } + if (entry.primary or entry.prunable) continue; + const status = try runGit(allocator, &.{ + "git", "-C", entry.path, "status", "--porcelain=v1", "--untracked-files=all", + }); + defer allocator.free(status.output); + var lines = std.mem.splitScalar(u8, status.output, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len < 2) continue; + entry.dirty = true; + if (std.mem.startsWith(u8, line, "??")) entry.untracked = true; + if (line[0] == 'U' or line[1] == 'U' or + (line[0] == 'A' and line[1] == 'A') or + (line[0] == 'D' and line[1] == 'D')) entry.conflicted = true; + } + entry.pushed = succeedsGit(allocator, &.{ + "git", "-C", entry.path, "rev-parse", "--verify", "@{u}", + }) and zeroCommitsAhead(allocator, entry.path); + entry.landed = succeedsGit(allocator, &.{ + "git", "-C", project_path, "merge-base", "--is-ancestor", + entry.branch, default_branch, + }); + } + return .{ + .entries = entries, + .default_branch = default_branch, + .project_path = try allocator.dupe(u8, project_path), + }; + } + +pub fn deinitInspection(allocator: std.mem.Allocator, inspection: *Inspection) void { + deinit(allocator, &inspection.entries); + allocator.free(inspection.default_branch); + allocator.free(inspection.project_path); +} + +pub fn reclaim(allocator: std.mem.Allocator, entries: []const Entry) !usize { + var removed: usize = 0; + for (entries) |entry| { + if (decision(entry) != .reclaimable) continue; + _ = try runGit(allocator, &.{ + "git", "-C", entry.path, "worktree", "remove", entry.path, + }); + removed += 1; + } + return removed; + } + +pub fn reclaimSelected( + allocator: std.mem.Allocator, + project_path: []const u8, + selected: []const []const u8, + bindings: []const Binding, +) !usize { + return reclaimSelectedWithPolicy(allocator, project_path, selected, bindings, .{}, false); +} + +pub fn reclaimSelectedWithPolicy( + allocator: std.mem.Allocator, + project_path: []const u8, + selected: []const []const u8, + bindings: []const Binding, + policy: Policy, + confirmed: bool, +) !usize { + if (!policy.allow_reclaim) return error.PolicyDisabled; + if (policy.confirm_each_reclaim and !confirmed) return error.ConfirmationRequired; + if (selected.len == 0) return error.UnsafeSelection; + var inspection = try inspect(allocator, project_path, bindings); + defer { + deinit(allocator, &inspection.entries); + allocator.free(inspection.default_branch); + } + try validateSelected(allocator, inspection.entries.items, selected, bindings); + var removed: usize = 0; + for (selected) |path| { + _ = try runGit(allocator, &.{ "git", "-C", project_path, "worktree", "remove", path }); + removed += 1; + } + return removed; +} + +pub fn validateSelected( + allocator: std.mem.Allocator, + entries: []const Entry, + selected: []const []const u8, + bindings: []const Binding, +) !void { + if (selected.len == 0) return error.UnsafeSelection; + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + for (selected) |path| { + if (path.len == 0 or seen.contains(path)) return error.UnsafeSelection; + try seen.put(path, {}); + for (bindings) |binding| { + if (std.mem.eql(u8, path, binding.path)) return error.UnsafeSelection; + } + const entry = selectedEntry(entries, path) orelse return error.UnsafeSelection; + if (decision(entry) != .reclaimable) return error.UnsafeSelection; + } +} + +const GitResult = struct { output: []u8 }; + +fn succeedsGit(allocator: std.mem.Allocator, args: []const []const u8) bool { + const result = runGit(allocator, args) catch return false; + allocator.free(result.output); + return true; + } + + fn zeroCommitsAhead(allocator: std.mem.Allocator, path: []const u8) bool { + const result = runGit(allocator, &.{ "git", "-C", path, "rev-list", "--count", "@{upstream}..HEAD" }) catch return false; + defer allocator.free(result.output); + return std.mem.eql(u8, std.mem.trim(u8, result.output, " \r\n"), "0"); + } + + fn landedOnDefault(allocator: std.mem.Allocator, project: []const u8, branch: []const u8, default_branch: []const u8) bool { + if (branch.len == 0 or default_branch.len == 0) return false; + const result = runGit(allocator, &.{ "git", "-C", project, "cherry", default_branch, branch }) catch return false; + defer allocator.free(result.output); + var lines = std.mem.splitScalar(u8, result.output, '\n'); + while (lines.next()) |line| { + if (std.mem.startsWith(u8, std.mem.trim(u8, line, " \r"), "+")) return false; + } + + return true; + } + +fn discoverDefault(allocator: std.mem.Allocator, project: []const u8, entries: []const Entry) ![]u8 { + const origin = runGit(allocator, &.{ "git", "-C", project, "symbolic-ref", "--short", "refs/remotes/origin/HEAD" }) catch null; + if (origin) |result| { + defer allocator.free(result.output); + const value = std.mem.trim(u8, result.output, " \r\n"); + if (value.len != 0) return allocator.dupe(u8, value); + } + for ([_][]const u8{ "main", "master" }) |candidate| { + if (succeedsGit(allocator, &.{ "git", "-C", project, "rev-parse", "--verify", candidate })) { + return allocator.dupe(u8, candidate); + } + } + if (entries.len != 0 and entries[0].branch.len != 0) { + return allocator.dupe(u8, entries[0].branch); + } + return error.GitFailed; +} + +fn runGit(allocator: std.mem.Allocator, args: []const []const u8) !GitResult { + var child = std.process.Child.init(args, allocator); + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Pipe; + try child.spawn(); + const output = try child.stdout.?.readToEndAlloc(allocator, 1024 * 1024); + const term = try child.wait(); + switch (term) { + .Exited => |code| if (code != 0) { + allocator.free(output); + return error.GitFailed; + }, + else => { + allocator.free(output); + return error.GitFailed; + }, + } + return .{ .output = output }; +} + +pub const Action = enum { inspect, reclaim }; + +pub const CommandError = error{EmptyProjectPath}; +pub const ReclaimError = error{PolicyDisabled, ConfirmationRequired, UnsafeSelection}; + +pub fn command( + allocator: std.mem.Allocator, + action: Action, + project_path: []const u8, +) (CommandError || std.mem.Allocator.Error)![]u8 { + if (project_path.len == 0) return error.EmptyProjectPath; + return switch (action) { + .inspect => std.fmt.allocPrint( + allocator, + "git -C \"{s}\" worktree list --porcelain", + .{project_path}, + ), + .reclaim => std.fmt.allocPrint( + allocator, + "git -C \"{s}\" worktree prune --verbose", + .{project_path}, + ), + }; +} + +pub fn parse(allocator: std.mem.Allocator, porcelain: []const u8) !std.array_list.Managed(Entry) { + var entries = std.array_list.Managed(Entry).init(allocator); + errdefer deinit(allocator, &entries); + var current: ?Entry = null; + var lines = std.mem.splitScalar(u8, porcelain, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r "); + if (line.len == 0) { + if (current) |entry| try entries.append(entry); + current = null; + continue; + } + if (std.mem.startsWith(u8, line, "worktree ")) { + if (current) |entry| try entries.append(entry); + current = .{ + .path = try allocator.dupe(u8, line["worktree ".len..]), + .branch = try allocator.dupe(u8, ""), + }; + } else if (current != null and std.mem.startsWith(u8, line, "branch ")) { + const branch = line["branch ".len..]; + const short = if (std.mem.startsWith(u8, branch, "refs/heads/")) + branch["refs/heads/".len..] + else + branch; + allocator.free(current.?.branch); + current.?.branch = try allocator.dupe(u8, short); + } else if (current != null and std.mem.startsWith(u8, line, "locked")) { + current.?.locked = true; + } else if (current != null and std.mem.startsWith(u8, line, "prunable")) { + current.?.prunable = true; + } + } + if (current) |entry| try entries.append(entry); + return entries; +} + +pub fn summarize(entries: []const Entry) Summary { + var result = Summary{}; + result.total = entries.len; + for (entries) |entry| { + if (decision(entry) == .reclaimable) { + result.reclaimable += 1; + } else if (entry.locked) { + result.blocked += 1; + } + } + return result; +} + +pub fn deinit(allocator: std.mem.Allocator, entries: *std.array_list.Managed(Entry)) void { + for (entries.items) |entry| { + allocator.free(entry.path); + allocator.free(entry.branch); + } + entries.deinit(); +} + +test "parses real git worktree porcelain and summarizes safe rows" { + const input = + \\worktree C:\work\graph + \\HEAD 1111111111111111111111111111111111111111 + \\branch refs/heads/main + \\ + \\worktree C:\work\review + \\HEAD 2222222222222222222222222222222222222222 + \\branch refs/heads/review + \\ + \\worktree C:\work\stale + \\HEAD 3333333333333333333333333333333333333333 + \\branch refs/heads/stale + \\prunable + \\ + \\worktree C:\work\locked + \\HEAD 4444444444444444444444444444444444444444 + \\branch refs/heads/locked + \\locked + ; + var entries = try parse(std.testing.allocator, input); + defer deinit(std.testing.allocator, &entries); + try std.testing.expectEqual(@as(usize, 4), entries.items.len); + try std.testing.expectEqualStrings("review", entries.items[1].branch); + const summary = summarize(entries.items); + try std.testing.expectEqual(@as(usize, 4), summary.total); + // Porcelain alone is not enough to prove pushed/landed; unknown facts stay + // non-reclaimable until inspection fills them in. + try std.testing.expectEqual(@as(usize, 0), summary.reclaimable); + try std.testing.expectEqual(@as(usize, 1), summary.blocked); +} + +test "porcelain edge cases preserve detached, locked, and prunable rows" { + const input = + \\worktree C:\work\detached + \\HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + \\detached + \\ + \\worktree C:\work\locked + \\HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + \\branch refs/heads/locked + \\locked reason + \\ + \\worktree C:\work\prunable + \\HEAD cccccccccccccccccccccccccccccccccccccccc + \\branch refs/heads/prunable + \\prunable stale admin + ; + var entries = try parse(std.testing.allocator, input); + defer deinit(std.testing.allocator, &entries); + try std.testing.expectEqual(@as(usize, 3), entries.items.len); + try std.testing.expectEqualStrings("", entries.items[0].branch); + try std.testing.expect(entries.items[1].locked); + try std.testing.expect(entries.items[2].prunable); +} + +test "hygiene commands reject empty paths and quote Windows paths" { + try std.testing.expectError(error.EmptyProjectPath, command(std.testing.allocator, .inspect, "")); + const inspect_command = try command(std.testing.allocator, .inspect, "C:\\work\\Graph Code"); + defer std.testing.allocator.free(inspect_command); + try std.testing.expectEqualStrings( + "git -C \"C:\\work\\Graph Code\" worktree list --porcelain", + inspect_command, + ); + const reclaim_command = try command(std.testing.allocator, .reclaim, "C:\\work\\Graph Code"); + defer std.testing.allocator.free(reclaim_command); + try std.testing.expectEqualStrings( + "git -C \"C:\\work\\Graph Code\" worktree prune --verbose", + reclaim_command, + ); +} + +test "reclaim classification fails closed for every unsafe signal" { + const clean = Entry{ + .path = @constCast("clean"), + .branch = @constCast("feature"), + .pushed = true, + .landed = true, + }; + try std.testing.expectEqual(ReclaimDecision.reclaimable, decision(clean)); + inline for ([_][]const u8{ + "primary", "locked", "dirty", "untracked", "conflicted", "unpushed", + "unlanded", "running binding", + }) |label| { + var candidate = clean; + if (std.mem.eql(u8, label, "primary")) candidate.primary = true; + if (std.mem.eql(u8, label, "locked")) candidate.locked = true; + if (std.mem.eql(u8, label, "dirty")) candidate.dirty = true; + if (std.mem.eql(u8, label, "untracked")) candidate.untracked = true; + if (std.mem.eql(u8, label, "conflicted")) candidate.conflicted = true; + if (std.mem.eql(u8, label, "unpushed")) candidate.pushed = false; + if (std.mem.eql(u8, label, "unlanded")) candidate.landed = false; + if (std.mem.eql(u8, label, "running binding")) candidate.bound_running = true; + try std.testing.expectEqual(ReclaimDecision.keep, decision(candidate)); + } + +} + +test "explicit row selection is independent of graph binding safety" { + var entries = [_]Entry{ + .{ .path = @constCast("C:\\safe"), .branch = @constCast("safe"), .pushed = true, .landed = true }, + .{ .path = @constCast("C:\\bound"), .branch = @constCast("bound"), .pushed = true, .landed = true, .bound_running = true }, + }; + try std.testing.expectEqual(ReclaimDecision.reclaimable, decision(selectedEntry(&entries, "C:\\safe").?)); + try std.testing.expectEqual(ReclaimDecision.keep, decision(selectedEntry(&entries, "C:\\bound").?)); +} + +test "policy decoding fails closed and round trips explicit settings" { + try std.testing.expectError(error.MalformedPolicy, decodePolicy("{}")); + try std.testing.expectError(error.MalformedPolicy, decodePolicy( + "{\"allowReclaim\":true,\"confirmEachReclaim\":false,\"unknown\":true}", + )); + try std.testing.expectError(error.MalformedPolicy, decodePolicy( + "{\"allowReclaim\":\"true\",\"confirmEachReclaim\":false}", + )); + try std.testing.expectError(error.MalformedPolicy, decodePolicy( + "{\"allowReclaim\":true,\"confirmEachReclaim\":}", + )); + const encoded = try encodePolicy(std.testing.allocator, .{ .allow_reclaim = true, .confirm_each_reclaim = false }); + defer std.testing.allocator.free(encoded); + const decoded = try decodePolicy(encoded); + try std.testing.expect(decoded.allow_reclaim); + try std.testing.expect(!decoded.confirm_each_reclaim); + try std.testing.expectEqual(ResolveAction.remove, decoded.effectiveResolveAction()); + try std.testing.expectEqual(@as(u32, 2), decoded.notice_size_gb); + try std.testing.expectEqual(@as(u32, 8), decoded.notice_count); + + var ask = Policy{}; + ask.applyResolveAction(.ask); + ask.notice_size_gb = 4; + ask.notice_count = 12; + const ask_encoded = try encodePolicy(std.testing.allocator, ask); + defer std.testing.allocator.free(ask_encoded); + const ask_decoded = try decodePolicy(ask_encoded); + try std.testing.expectEqual(ResolveAction.ask, ask_decoded.effectiveResolveAction()); + try std.testing.expectEqual(@as(u32, 4), ask_decoded.notice_size_gb); + try std.testing.expectEqual(@as(u32, 12), ask_decoded.notice_count); + try std.testing.expect(!canReclaim(.{ .path = @constCast("safe"), .branch = @constCast("main"), .pushed = true, .landed = true }, .{}, true)); +} + +test "Explorer command line preserves Windows Unicode arguments" { + const command_line = try explorerCommandLine(std.testing.allocator, "C:\\工作 space\\review"); + defer std.testing.allocator.free(command_line); + try std.testing.expectEqualStrings("explorer.exe /select,\"C:\\工作 space\\review\"", command_line); +} + +test "selected batch validation rejects duplicate missing bound and unsafe rows before removal" { + var entries = [_]Entry{ + .{ .path = @constCast("safe"), .branch = @constCast("safe"), .pushed = true, .landed = true }, + .{ .path = @constCast("dirty"), .branch = @constCast("dirty"), .dirty = true, .pushed = true, .landed = true }, + }; + try validateSelected(std.testing.allocator, &entries, &[_][]const u8{"safe"}, &.{}); + try std.testing.expectError(error.UnsafeSelection, validateSelected( + std.testing.allocator, &entries, &[_][]const u8{"safe", "safe"}, &.{}, + )); + try std.testing.expectError(error.UnsafeSelection, validateSelected( + std.testing.allocator, &entries, &[_][]const u8{"missing"}, &.{}, + )); + try std.testing.expectError(error.UnsafeSelection, validateSelected( + std.testing.allocator, &entries, &[_][]const u8{"safe"}, &.{.{ .path = "safe" }}, + )); + try std.testing.expectError(error.UnsafeSelection, validateSelected( + std.testing.allocator, &entries, &[_][]const u8{"dirty"}, &.{}, + )); +} diff --git a/graphcode-windows/src/main.zig b/graphcode-windows/src/main.zig new file mode 100644 index 00000000..93d9deff --- /dev/null +++ b/graphcode-windows/src/main.zig @@ -0,0 +1,37 @@ +const std = @import("std"); +const App = @import("App.zig").App; +const c = @import("Win32.zig").c; +const build_options = @import("build_options"); + +pub fn main() !void { + const allocator = std.heap.c_allocator; + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + for (args[1..]) |arg| { + if (std.mem.eql(u8, arg, "--version")) { + var stdout = std.fs.File.stdout().writer(&.{}); + try stdout.interface.print("{s}\n", .{build_options.version}); + return; + } + } + var app = App.init(allocator) catch |err| { + if (err == error.InstanceAlreadyRunning) { + @import("MainWindow.zig").restoreExistingInstance(); + return; + } + return err; + }; + defer app.deinit(); + app.configureArgs(args[1..]); + try app.run(); +} + +pub export fn WinMain( + _: c.HINSTANCE, + _: c.HINSTANCE, + _: [*:0]u16, + _: c.INT, +) callconv(.winapi) c.INT { + main() catch return 1; + return 0; +} diff --git a/graphcode/Sources/Clients/GitClient.swift b/graphcode/Sources/Clients/GitClient.swift index 86dbcda2..c6c99927 100644 --- a/graphcode/Sources/Clients/GitClient.swift +++ b/graphcode/Sources/Clients/GitClient.swift @@ -218,8 +218,7 @@ private func runClone( // stderr lines, and `commandFailed` should carry them rather than a bare status. let recentLines = OSAllocatedUnfairLock(initialState: [String]()) let onLine: @Sendable (String) -> Void = { line in - let shown = - credentials.map { line.replacingOccurrences(of: $0, with: "•••") } ?? line + let shown = GitClient.redactCloneOutput(line, credentials: credentials) recentLines.withLock { recent in recent.append(shown) if recent.count > 5 { recent.removeFirst() } @@ -267,13 +266,8 @@ private func runClone( private func cloneProcess( url: String, destinationPath: String, branch: String?, depth: Int? ) -> Process { - var arguments = ["git", "clone", "--progress"] - if let branch, !branch.isEmpty { arguments += ["--branch", branch] } - if let depth { arguments += ["--depth", String(depth)] } - // `--` before the positionals so a pasted URL like `--upload-pack=` reaches git - // as a repository to clone, not an option to obey (git's clone-URL option injection, - // CVE-2017-1000117 shape). Documented git syntax: `clone [] [--] [dir]`. - arguments += ["--", url, destinationPath] + let arguments = GitClient.cloneArguments( + url: url, destinationPath: destinationPath, branch: branch, depth: depth) let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -291,6 +285,26 @@ private func cloneProcess( return process } +extension GitClient { + /// The native-process argv used by clone. Keeping this pure makes the option boundary + /// executable in tests and prevents a future UI caller from rebuilding it through a + /// shell string. + static func cloneArguments( + url: String, destinationPath: String, branch: String?, depth: Int? + ) -> [String] { + var arguments = ["git", "clone", "--progress"] + if let branch, !branch.isEmpty { arguments += ["--branch", branch] } + if let depth { arguments += ["--depth", String(depth)] } + // `--` before the positionals keeps pasted URLs and Unicode destinations positional. + arguments += ["--", url, destinationPath] + return arguments + } + + static func redactCloneOutput(_ line: String, credentials: String?) -> String { + credentials.map { line.replacingOccurrences(of: $0, with: "•••") } ?? line + } +} + /// Feeds `onLine` every completed line out of `pipe`, treating `\r` (git's redraw-in- /// place separator) the same as `\n`, and dropping blanks. private func attachLineReader(to pipe: Pipe, onLine: @escaping @Sendable (String) -> Void) { diff --git a/graphcode/Sources/Clients/OrchestratorClient.swift b/graphcode/Sources/Clients/OrchestratorClient.swift index 75acfdbb..a3e056e6 100644 --- a/graphcode/Sources/Clients/OrchestratorClient.swift +++ b/graphcode/Sources/Clients/OrchestratorClient.swift @@ -17,6 +17,23 @@ struct OrchestratorClient: Sendable { var send: @Sendable (_ command: DaemonCommand) async throws -> Void } +private final class ReaderToken: @unchecked Sendable { + private let lock = NSLock() + private var readerID: UInt64? + + func set(_ readerID: UInt64) { + lock.lock() + self.readerID = readerID + lock.unlock() + } + + var value: UInt64? { + lock.lock() + defer { lock.unlock() } + return readerID + } +} + enum OrchestratorClientError: Error, Equatable { case connectFailed(errno: Int32) } @@ -28,7 +45,7 @@ extension OrchestratorClient: DependencyKey { /// `DaemonConnection.connection`. Parameterized on the socket path so tests can point /// a client at a socket they own instead of the real daemon's. static func live(socketPath: URL) -> OrchestratorClient { - let connection = DaemonConnection(socketPath: socketPath) + let connection = AppDaemonConnection(socketPath: socketPath) return OrchestratorClient( connect: { connection.events() }, send: { command in try await connection.send(command) } @@ -45,7 +62,7 @@ extension DependencyValues { /// Owns the one socket connection to `graphcoded`, connecting lazily and retrying with /// backoff — the app can launch before the daemon has finished starting up. -private actor DaemonConnection { +private actor AppDaemonConnection { private let socketPath: URL /// The one connect attempt, in flight or finished — **not** a bare file descriptor. @@ -60,11 +77,18 @@ private actor DaemonConnection { /// landed on the descriptor nobody read, and the UI never saw an event. Storing the /// `Task` means the second caller awaits the first caller's attempt: one socket, one /// reader, replies land where they're expected. - private var connection: Task? + private var connection: Task? /// Bumped whenever `connection` is dropped, so a late failure handler can tell whether /// the attempt it was holding is still the current one. private var generation = 0 + private var nextReaderID: UInt64 = 0 + private var activeReaderID: UInt64? + /// A replacement socket must be joined exactly once, regardless of whether the reader + /// or a concurrent send is first to observe it. + private var rejoinConnectionID: UUID? + private var restoreTask: Task? + private var globalJoinTask: Task? init(socketPath: URL) { self.socketPath = socketPath @@ -78,26 +102,53 @@ private actor DaemonConnection { /// would look connected but never update again. nonisolated func events() -> AsyncStream { AsyncStream { continuation in + let token = ReaderToken() let task = Task { + let readerID = await beginReader() + token.set(readerID) + defer { + continuation.finish() + Task { await endReader(readerID) } + } while !Task.isCancelled { - var connectedDescriptor: Int32? + var connectedConnection: (any DaemonConnection)? do { - let fileDescriptor = try await ensureConnected() - connectedDescriptor = fileDescriptor - if await isReconnect() { try await rejoinProjects() } - while true { - let data = try await readFrameAsync(from: fileDescriptor) + guard await isCurrentReader(readerID) else { return } + let connection = try await ensureConnected() + guard await isCurrentReader(readerID) else { + try? await connection.close() + return + } + connectedConnection = connection + if await isReconnect() { try await rejoinProjects(on: connection) } + while !Task.isCancelled { + guard await isCurrentReader(readerID) else { return } + let data = try await connection.receiveFrame() let event = try JSONDecoder().decode(DaemonEvent.self, from: data) continuation.yield(event) } } catch { - if let connectedDescriptor { await invalidate(connectedDescriptor) } - try? await Task.sleep(for: .seconds(1)) + if let connectedConnection { + await readerFailed(readerID, connection: connectedConnection) + } + guard !Task.isCancelled, await isCurrentReader(readerID) else { return } + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + } + } + } + continuation.onTermination = { _ in + task.cancel() + Task { + while token.value == nil { await Task.yield() } + if let readerID = token.value { + await self.endReader(readerID) } } - continuation.finish() } - continuation.onTermination = { _ in task.cancel() } } } @@ -110,6 +161,53 @@ private actor DaemonConnection { return hasConnectedBefore } + private func beginReader() async -> UInt64 { + let readerID = nextReaderID + nextReaderID = nextReaderID == UInt64.max ? 0 : nextReaderID + 1 + let hadReader = activeReaderID != nil + activeReaderID = readerID + guard hadReader else { return readerID } + + let oldConnection = connection + connection = nil + generation += 1 + clearRejoinState() + if let oldConnection { + oldConnection.cancel() + if let resolved = try? await oldConnection.value { + try? await resolved.close() + } + } + return readerID + } + + private func isCurrentReader(_ readerID: UInt64) -> Bool { + activeReaderID == readerID + } + + private func endReader(_ readerID: UInt64) async { + guard activeReaderID == readerID else { return } + activeReaderID = nil + let currentConnection = connection + connection = nil + generation += 1 + clearRejoinState() + if let currentConnection { + currentConnection.cancel() + if let resolved = try? await currentConnection.value { + try? await resolved.close() + } + } + } + + private func readerFailed(_ readerID: UInt64, connection: any DaemonConnection) async { + guard activeReaderID == readerID else { + try? await connection.close() + return + } + await invalidate(connection) + } + /// Re-announces which projects this client wants, on a socket that replaced one that /// failed. /// @@ -124,23 +222,34 @@ private actor DaemonConnection { /// The same two commands the launch path sends: the daemon's own open-projects set is /// the right one to restore from, and the global graph is joined by name because it is /// deliberately not in that set. - private func rejoinProjects() async throws { - try await send(.restoreOpenProjects) - try await send(.openGlobalGraph) + private func rejoinProjects(on connection: any DaemonConnection) async throws { + try await ensureRejoined(connection) } func send(_ command: DaemonCommand) async throws { - let fileDescriptor = try await ensureConnected() - let data = try JSONEncoder().encode(command) + let connection = try await ensureConnected() do { - try await writeFrameAsync(data, to: fileDescriptor) + switch command { + case .restoreOpenProjects, .openGlobalGraph: + // These are the public join commands used by app startup. Coalesce each with + // reconnect rejoin so concurrent startup sends cannot duplicate a join. + try await sendJoin(command, on: connection) + case .listRecentProjects, .listQuickChats, .createQuickChat, .openQuickChat, + .renameQuickChat, .deleteQuickChat: + // Quick chats hang off no project, so they need no rejoin — the raw path is the + // same one `listRecentProjects` takes. + try await sendRaw(command, on: connection) + case .openProject, .closeProject, .forgetProject, .deleteProjectGraph, .graphCommand: + try await ensureRejoined(connection) + try await sendRaw(command, on: connection) + } } catch { - await invalidate(fileDescriptor) + await invalidate(connection) throw error } } - private func ensureConnected() async throws -> Int32 { + private func ensureConnected() async throws -> any DaemonConnection { if let connection { return try await connection.value } let attemptGeneration = generation let attempt = Task { try await connectWithBackoff() } @@ -158,36 +267,126 @@ private actor DaemonConnection { } } + /// Coalesces the two commands that establish this client's project/global membership. + /// + /// The task is keyed by the transport identity rather than the actor's generation so a + /// send-created replacement socket and the reader's reconnect path share the same work. + private func ensureRejoined(_ connection: any DaemonConnection) async throws { + try await sendJoin(.restoreOpenProjects, on: connection) + try await sendJoin(.openGlobalGraph, on: connection) + } + + private func sendJoin( + _ command: DaemonCommand, + on connection: any DaemonConnection + ) async throws { + if rejoinConnectionID != connection.id { + clearRejoinState() + rejoinConnectionID = connection.id + } + let existingTask: Task? + switch command { + case .restoreOpenProjects: + existingTask = restoreTask + case .openGlobalGraph: + existingTask = globalJoinTask + default: + preconditionFailure("only join commands can use sendJoin") + } + if let existingTask { + do { + try await existingTask.value + return + } catch { + if rejoinConnectionID == connection.id { + clearJoinTask(command) + } + throw error + } + } + + let task = Task { () throws -> Void in + try await sendRaw(command, on: connection) + } + switch command { + case .restoreOpenProjects: + restoreTask = task + case .openGlobalGraph: + globalJoinTask = task + default: + preconditionFailure("only join commands can use sendJoin") + } + do { + try await task.value + } catch { + if rejoinConnectionID == connection.id { + clearJoinTask(command) + } + throw error + } + } + + private func clearJoinTask(_ command: DaemonCommand) { + switch command { + case .restoreOpenProjects: + restoreTask = nil + case .openGlobalGraph: + globalJoinTask = nil + default: + break + } + } + + private func sendRaw(_ command: DaemonCommand, on connection: any DaemonConnection) async throws { + try await connection.sendFrame(try JSONEncoder().encode(command)) + } + + private func clearRejoinState() { + restoreTask?.cancel() + globalJoinTask?.cancel() + restoreTask = nil + globalJoinTask = nil + rejoinConnectionID = nil + } + /// Drops the shared connection after an I/O failure, so the next `send` or `events()` /// dials again instead of writing into a socket the daemon has gone from. /// - /// Deliberately does not `close` the descriptor: `events()` can be parked in a blocking - /// `read` on it from another thread, and closing under that read frees the number for - /// any other socket the process opens next. Costs one stranded descriptor per daemon - /// restart, which the process reclaims on exit. - private func invalidate(_ fileDescriptor: Int32) async { - guard let connection, let currentDescriptor = try? await connection.value, - currentDescriptor == fileDescriptor + /// Closes the failed transport as well as dropping it: `events()` can be parked in a + /// blocking `read` on another thread, and closing under that read is what wakes the old + /// reader so it cannot race a replacement connection. + private func invalidate(_ failedConnection: any DaemonConnection) async { + guard let connection, let currentConnection = try? await connection.value, + currentConnection.id == failedConnection.id else { return } self.connection = nil generation += 1 + clearRejoinState() + connection.cancel() + try? await failedConnection.close() } - private func connectWithBackoff() async throws -> Int32 { + private func connectWithBackoff() async throws -> any DaemonConnection { var lastError: any Error = OrchestratorClientError.connectFailed(errno: 0) for attempt in 0..<10 { + try Task.checkCancellation() do { return try await connectAsync() } catch { lastError = error - try? await Task.sleep(for: .milliseconds(200 * (attempt + 1))) + do { + try await Task.sleep(for: .milliseconds(200 * (attempt + 1))) + } catch { + throw CancellationError() + } } } + throw lastError } @Sendable - private func connectAsync() async throws -> Int32 { + private func connectAsync() async throws -> any DaemonConnection { let path = socketPath.path return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { @@ -208,6 +407,7 @@ private actor DaemonConnection { strncpy(pathPointer, cPath, MemoryLayout.size(ofValue: pathField.pointee) - 1) } } + } let connectResult = withUnsafePointer(to: &address) { addressPointer -> Int32 in @@ -221,34 +421,11 @@ private actor DaemonConnection { continuation.resume(throwing: OrchestratorClientError.connectFailed(errno: capturedErrno)) return } - continuation.resume(returning: fd) - } - } - } -} - -@Sendable -private func readFrameAsync(from fileDescriptor: Int32) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - do { - continuation.resume(returning: try FramedMessageIO.readFrame(from: fileDescriptor)) - } catch { - continuation.resume(throwing: error) - } - } - } -} - -@Sendable -private func writeFrameAsync(_ data: Data, to fileDescriptor: Int32) async throws { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { - do { - try FramedMessageIO.writeFrame(data, to: fileDescriptor) - continuation.resume() - } catch { - continuation.resume(throwing: error) + continuation.resume( + returning: UnixSocketConnection( + fileDescriptor: fd, + endpoint: .unixSocket(URL(fileURLWithPath: path)), + writeTimeout: 5)) } } } diff --git a/graphcode/Sources/Features/App/AppFeature+QuickChats.swift b/graphcode/Sources/Features/App/AppFeature+QuickChats.swift index b1ad33da..299e592f 100644 --- a/graphcode/Sources/Features/App/AppFeature+QuickChats.swift +++ b/graphcode/Sources/Features/App/AppFeature+QuickChats.swift @@ -19,7 +19,7 @@ extension AppFeature { title: "Chat — \(Date().formatted(.dateTime.month(.abbreviated).day()))", backend: GraphcodeSettingsStore.load().defaultBackend) state.quickChats.append(chat) - quickChatStore.save(Array(state.quickChats)) + try? quickChatStore.save(Array(state.quickChats)) openQuickChat(chat, &state) recordVisit(.quickChat(id: chat.id), &state) return .none @@ -57,7 +57,7 @@ extension AppFeature { state.chatPendingRename = nil guard !trimmed.isEmpty, state.quickChats[id: id] != nil else { return .none } state.quickChats[id: id]?.title = trimmed - quickChatStore.save(Array(state.quickChats)) + try? quickChatStore.save(Array(state.quickChats)) // The open workspace carries a synthetic copy of this chat, so its header would // otherwise keep the old name until the chat was reopened. if state.openLoop?.node.id == id { @@ -79,7 +79,7 @@ extension AppFeature { state.chatPendingDeletion = nil guard state.quickChats[id: id] != nil else { return .none } state.quickChats.remove(id: id) - quickChatStore.save(Array(state.quickChats)) + try? quickChatStore.save(Array(state.quickChats)) if state.openLoop?.node.id == id { closeOpenWorkspace(&state) // Back to the chats' own canvas rather than to some folder: the chat that was diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 3ff4225e..58038d48 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -372,6 +372,11 @@ struct AppFeature { case .errorOccurred(let message): state.welcome.errorMessage = message return .none + + // Only the Windows shell learns about quick chats from the daemon; this app owns + // them locally through `quickChatStore`, so the broadcast is redundant here. + case .quickChatsListed, .quickChatChanged, .quickChatDeleted, .quickChatActivity: + return .none } case .projectHeaderTapped(let path): diff --git a/graphcode/Sources/Features/Project/NodeDraftForm.swift b/graphcode/Sources/Features/Project/NodeDraftForm.swift index ed2ec2c4..7e14ade5 100644 --- a/graphcode/Sources/Features/Project/NodeDraftForm.swift +++ b/graphcode/Sources/Features/Project/NodeDraftForm.swift @@ -96,6 +96,17 @@ struct NodeDraftForm: View { } .labelsHidden() } + DraftField(label: "Model") { + Picker("", selection: Binding( + get: { store.draftModelTier ?? .standard }, + set: { store.draftModelTier = $0 } + )) { + ForEach(ModelTier.allCases, id: \.self) { tier in + Text(tier.displayName).tag(tier) + } + } + .labelsHidden() + } if !isRemoteProject && !store.graph.isGlobal { DraftField(label: "Branch") { Picker("", selection: $store.draftWorktree) { diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index e537e901..44a35b1c 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -86,6 +86,7 @@ struct ProjectFeature { var draftSchedule: CompositeSchedule = .daily var draftScheduleTime = "09:00" var draftBackend: CLISessionBackendKind = .claudeCode + var draftModelTier: ModelTier? var draftWorktree: WorktreeSelection = .none var draftBranch = "" /// Set when the form was opened from a node card's + handle: the node the new loop @@ -316,6 +317,8 @@ struct ProjectFeature { state.connectionError = message case .recentProjectsListed: break // Not this feature's concern — AppFeature routes this to `welcome`. + case .quickChatsListed, .quickChatChanged, .quickChatDeleted, .quickChatActivity: + break // Quick chats belong to no project — AppFeature owns them. } return .none @@ -767,6 +770,8 @@ extension ProjectFeature { // The parent's backend when there is one; the human's default otherwise // (Settings → Sessions), never a hardcoded one. state.draftBackend = backend ?? GraphcodeSettingsStore.load().defaultBackend + let settings = GraphcodeSettingsStore.load() + state.draftModelTier = settings.autoSelectsModel ? nil : settings.defaultModelTier state.draftWorktree = .none state.draftBranch = "" state.draftParentNodeID = parentNodeID diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index b72c14ea..95027a12 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -67,6 +67,7 @@ extension ProjectFeature.State { tokenBudget: parsedBudget) : nil, backend: draftBackend, + modelTier: draftModelTier, // Only an *existing* worktree can be bound here; a new one has to be created on // disk first, which is `.createNodeConfirmed`'s job. worktree: { diff --git a/graphcode/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index 8d89f769..438b2432 100644 --- a/graphcode/Sources/Features/Settings/SettingsModel.swift +++ b/graphcode/Sources/Features/Settings/SettingsModel.swift @@ -23,23 +23,25 @@ final class SettingsModel { } } - /// The update channel as a switch (#36) — app-only, so `UserDefaults` rather than - /// `GraphcodeSettings`: the daemon never checks for updates, and `UpdateClient` reads - /// the same `updateChannel` key. Starts on the install's effective channel — a beta - /// build reads as on — and the first flip writes an explicit override either way. + /// The update channel as a switch (#36). It is persisted in the shared settings file + /// and mirrored to the update client's legacy `UserDefaults` override. var betaUpdates: Bool { didSet { + guard betaUpdates != oldValue else { return } + settings.betaUpdates = betaUpdates UserDefaults.standard.set(betaUpdates ? "beta" : "stable", forKey: "updateChannel") } } private init() { - settings = GraphcodeSettingsStore.load() + let persisted = GraphcodeSettingsStore.load() + settings = persisted let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" betaUpdates = - UpdateChannel.channel( + persisted.betaUpdates + || UpdateChannel.channel( for: version, override: UserDefaults.standard.string(forKey: "updateChannel")) - == .beta + == .beta } } diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index ec6747f8..791b04f9 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -98,15 +98,19 @@ struct SettingsView: View { } Section { + Picker("Default model", selection: $model.settings.defaultModelTier) { + ForEach(ModelTier.allCases, id: \.self) { tier in + Text(tier.displayName).tag(tier) + } + } Toggle("Pick a model for each loop", isOn: $model.settings.autoSelectsModel) } header: { Text("Model") } footer: { Text( - "Off, graphcode passes no model and your CLI runs on whatever it's already set " - + "up to use. On, a loop with no model of its own is routed by its type — " - + "turn-based loops get a more capable model, time-based polling a faster one. " - + "A model set on an individual loop always wins either way." + "The default model tier is copied into new loops. On, an unpinned loop is instead " + + "routed by its type — turn-based loops get a more capable model, time-based " + + "polling a faster one. A model set on an individual loop always wins." ) .font(.caption2) .foregroundStyle(.secondary) diff --git a/graphcode/Tests/CloneRepositoryTests.swift b/graphcode/Tests/CloneRepositoryTests.swift index 240da5ed..9070f6d8 100644 --- a/graphcode/Tests/CloneRepositoryTests.swift +++ b/graphcode/Tests/CloneRepositoryTests.swift @@ -34,6 +34,23 @@ struct CloneRepositoryTests { #expect(GitClient.cloneCredentials(of: "https://github.com/a/b.git") == nil) } + @Test + func cloneUsesNativeSeparatedArgumentsAndRedactsCredentials() { + let url = "https://token@example.com/acme/über.git" + let destination = #"C:\Users\me\Проекты\über"# + let arguments = GitClient.cloneArguments( + url: url, destinationPath: destination, branch: "main", depth: 1) + + #expect(Array(arguments.prefix(3)) == ["git", "clone", "--progress"]) + #expect(arguments.contains("--")) + #expect(arguments.last == destination) + #expect( + GitClient.redactCloneOutput( + "fatal: https://token@example.com/acme/über.git", credentials: "token") + == "fatal: https://•••@example.com/acme/über.git") + #expect(arguments[arguments.firstIndex(of: "--")! + 1] == url) + } + // MARK: - Draft derivations @Test diff --git a/graphcode/Tests/DaemonSocketClientTests.swift b/graphcode/Tests/DaemonSocketClientTests.swift index 00a0000b..631674c7 100644 --- a/graphcode/Tests/DaemonSocketClientTests.swift +++ b/graphcode/Tests/DaemonSocketClientTests.swift @@ -88,6 +88,103 @@ struct DaemonSocketClientTests { #expect(received.project.path == "/tmp/wanted") } + #if canImport(Darwin) + @Test + func syncReceiveRejectsUInt32MaximumBeforeAllocatingLegacyPayload() throws { + var fds: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0) + defer { + close(fds[0]) + close(fds[1]) + } + + let header = Data([0xff, 0xff, 0xff, 0xff]) + let written = header.withUnsafeBytes { rawBuffer in + Darwin.write(fds[1], rawBuffer.baseAddress, rawBuffer.count) + } + #expect(written == header.count) + + let connection = UnixSocketConnection(fileDescriptor: fds[0]) + #expect(throws: FramedMessageIO.IOError.payloadTooLarge) { + _ = try connection.receiveFrameSync() + } + } + + @Test + func postHandshakeReadKeepsIdleConnectionsAliveUntilAFrameStarts() async throws { + var fds: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0) + defer { + close(fds[0]) + close(fds[1]) + } + + let connection = UnixSocketConnection(fileDescriptor: fds[0]) + let payload = Data("idle-then-frame".utf8) + let reader = Task { + try await connection.receiveFrameWithPostHandshakeDeadline(0.2) + } + try await Task.sleep(for: .milliseconds(350)) + try FramedMessageIO.writeFrame(payload, to: fds[1]) + + let received = try await reader.value + #expect(received == payload) + } + + @Test + func postHandshakeReadTimesOutAfterAStalledHeaderOrPayload() async throws { + var headerFds: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &headerFds) == 0) + defer { + close(headerFds[0]) + close(headerFds[1]) + } + + let headerConnection = UnixSocketConnection(fileDescriptor: headerFds[0]) + let headerReader = Task { + try await headerConnection.receiveFrameWithPostHandshakeDeadline(0.2) + } + let firstHeaderByte: UInt8 = 0 + let headerByteCount = withUnsafeBytes(of: firstHeaderByte) { rawBuffer in + write(headerFds[1], rawBuffer.baseAddress, rawBuffer.count) + } + #expect(headerByteCount == 1) + do { + _ = try await headerReader.value + Issue.record("a partial header should time out") + } catch FramedMessageIO.IOError.readFailed(let code) { + #expect(code == EAGAIN || code == EWOULDBLOCK) + } + + var payloadFds: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &payloadFds) == 0) + defer { + close(payloadFds[0]) + close(payloadFds[1]) + } + + let payloadConnection = UnixSocketConnection(fileDescriptor: payloadFds[0]) + let payloadReader = Task { + try await payloadConnection.receiveFrameWithPostHandshakeDeadline(0.2) + } + let header = try DaemonFrameHeader.encodeLength(4) + try header.withUnsafeBytes { rawBuffer in + #expect(write(payloadFds[1], rawBuffer.baseAddress, rawBuffer.count) == rawBuffer.count) + } + let firstPayloadByte: UInt8 = 0x41 + let payloadByteCount = withUnsafeBytes(of: firstPayloadByte) { rawBuffer in + write(payloadFds[1], rawBuffer.baseAddress, rawBuffer.count) + } + #expect(payloadByteCount == 1) + do { + _ = try await payloadReader.value + Issue.record("a partial payload should time out") + } catch FramedMessageIO.IOError.readFailed(let code) { + #expect(code == EAGAIN || code == EWOULDBLOCK) + } + } + #endif + /// Dialling is retried; anything after the first write is not. Nothing has been sent when /// a dial fails, so a redial cannot duplicate a mutation — whereas `node create`, `node /// send` and `node memo` are not idempotent, which is why a mid-exchange diff --git a/graphcode/Tests/GraphStoreTests.swift b/graphcode/Tests/GraphStoreTests.swift index a6f47d98..033209aa 100644 --- a/graphcode/Tests/GraphStoreTests.swift +++ b/graphcode/Tests/GraphStoreTests.swift @@ -348,6 +348,48 @@ struct GraphStoreTests { #expect(await store.graph.nodes.isEmpty) } + @Test + func anInvalidDraftReturnsARejectedCommandResult() async { + let store = GraphStore() + + let result = await store.handle( + .createNode(NodeDraft(title: "No goal", loopType: .goalBased))) + + guard case .rejected(let message, let snapshot) = result else { + Issue.record("expected a rejected command result") + return + } + #expect(message == "node creation refused: draft is invalid") + #expect(snapshot.nodes.isEmpty) + #expect(await store.graph.nodes.isEmpty) + } + + @Test + func concurrentCommandsReturnTheirOwnPostCommandSnapshots() async { + let store = GraphStore() + let firstDraft = turnDraft("First", check: "Sound?") + let secondDraft = turnDraft("Second", check: "Clear?") + + async let firstResult = store.handle(.createNode(firstDraft)) + async let secondResult = store.handle(.createNode(secondDraft)) + let results = [await firstResult, await secondResult] + let snapshots = results.compactMap { result -> LoopGraph? in + guard case .applied(let graph) = result else { + Issue.record("expected both concurrent commands to apply") + return nil + } + return graph + } + + #expect(snapshots.count == 2) + #expect(snapshots.contains { $0.nodes.count == 1 && $0.nodes.contains { $0.title == "First" } }) + #expect( + snapshots.contains { + $0.nodes.count == 2 + && Set($0.nodes.map(\.title)) == Set(["First", "Second"]) + }) + } + @Test func anInvalidDraftStartsNoSession() async { // Rejecting the node but still launching its session would leave an orphan `claude` diff --git a/graphcode/Tests/GraphcodeSettingsTests.swift b/graphcode/Tests/GraphcodeSettingsTests.swift index cb4f5e0e..215daf77 100644 --- a/graphcode/Tests/GraphcodeSettingsTests.swift +++ b/graphcode/Tests/GraphcodeSettingsTests.swift @@ -18,6 +18,7 @@ struct GraphcodeSettingsTests { func theDefaultsAreWhatWasHardcodedBefore() { let settings = GraphcodeSettings() #expect(settings.defaultBackend == .claudeCode) + #expect(settings.defaultModelTier == .standard) #expect(settings.claudePermissionMode == .auto) #expect(settings.copilotPermissions == .allowEverything) #expect(settings.briefsSessionsAboutTheGraph) @@ -28,13 +29,28 @@ struct GraphcodeSettingsTests { let url = temporaryURL() defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let settings = GraphcodeSettings( - defaultBackend: .copilotCLI, claudePermissionMode: .bypassPermissions, - copilotPermissions: .ask, briefsSessionsAboutTheGraph: false) + defaultBackend: .copilotCLI, defaultModelTier: .capable, + codexApprovals: .unsandboxed, + claudePermissionMode: .bypassPermissions, + copilotPermissions: .ask, briefsSessionsAboutTheGraph: false, + autoSelectsModel: true, showsActivityStrip: true, betaUpdates: true) #expect(GraphcodeSettingsStore.save(settings, to: url)) #expect(GraphcodeSettingsStore.load(from: url) == settings) } + @Test + func defaultsAreCopiedIntoANewNodeDraft() { + let settings = GraphcodeSettings(defaultBackend: .copilotCLI, defaultModelTier: .capable) + #expect(settings.defaultBackend == .copilotCLI) + #expect(settings.defaultModelTier == .capable) + let draft = NodeDraft( + title: "Ship", loopType: .goalBased, goal: GoalSpec(summary: "Tests pass"), + backend: settings.defaultBackend, modelTier: settings.defaultModelTier) + #expect(draft.backend == .copilotCLI) + #expect(draft.modelTier == .capable) + } + @Test func aMissingOrCorruptFileFallsBackToDefaults() throws { // Refusing to start sessions because a preferences file didn't parse would be a far diff --git a/graphcode/Tests/OrchestratorClientTests.swift b/graphcode/Tests/OrchestratorClientTests.swift index 3960bea6..b1a9a492 100644 --- a/graphcode/Tests/OrchestratorClientTests.swift +++ b/graphcode/Tests/OrchestratorClientTests.swift @@ -16,6 +16,51 @@ import Testing /// could never open a project. These tests pin the invariant against a stand-in daemon. @Suite struct OrchestratorClientTests { + #if canImport(Darwin) + @Test + func concurrentUnixSocketFramesRemainWhole() async throws { + var descriptors = [Int32](repeating: 0, count: 2) + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { + throw OrchestratorClientError.connectFailed(errno: errno) + } + defer { close(descriptors[1]) } + + let sender = UnixSocketConnection(fileDescriptor: descriptors[0]) + let payloads = [ + Data(repeating: 0x41, count: 256 * 1024), + Data(repeating: 0x42, count: 256 * 1024), + ] + let receiver = Task<[Data], Error> { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global().async { + do { + continuation.resume( + returning: [ + try FramedMessageIO.readFrame(from: descriptors[1]), + try FramedMessageIO.readFrame(from: descriptors[1]), + ]) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + try await withThrowingTaskGroup(of: Void.self) { group in + for payload in payloads { + group.addTask { + try await sender.sendFrame(payload) + } + } + try await group.waitForAll() + } + sender.closeSync() + + let received = try await receiver.value + #expect(Set(received) == Set(payloads)) + } + #endif + @Test func connectAndSendShareOneSocket() async throws { let daemon = try StubDaemon() @@ -56,6 +101,24 @@ struct OrchestratorClientTests { #expect(await received == .errorOccurred("late but connected")) } + @Test + func cancellingBeforeConnectStopsRetrySleepAndFutureDial() async throws { + let socketPath = StubDaemon.temporarySocketPath() + let client = OrchestratorClient.live(socketPath: socketPath) + let reader = Task { + for await _ in client.connect() {} + } + + try await Task.sleep(for: .milliseconds(100)) + reader.cancel() + await reader.value + + let daemon = try StubDaemon(socketPath: socketPath) + defer { daemon.stop() } + try await Task.sleep(for: .milliseconds(500)) + #expect(daemon.acceptedConnectionCount == 0) + } + @Test func reconnectingRejoinsTheProjectsItHadOpen() async throws { // Joining is per-connection on the daemon's side, and the app asked to join once, from @@ -87,6 +150,66 @@ struct OrchestratorClientTests { #expect(await received == .errorOccurred("after reconnect")) } + @Test + func sendOnReplacementSocketWaitsForExactlyOneRejoin() async throws { + let daemon = try StubDaemon() + defer { daemon.stop() } + let client = OrchestratorClient.live(socketPath: daemon.socketPath) + + let reader = Task { + for await _ in client.connect() {} + } + try await client.send(.listRecentProjects) + #expect(await daemon.nextCommand() == .listRecentProjects) + + daemon.closeConnection(at: 0) + let sendTask = Task { + try await client.send(.openProject(path: "/work/send-before-rejoin")) + } + + #expect(await daemon.nextCommand(onConnection: 1) == .restoreOpenProjects) + #expect(await daemon.nextCommand(onConnection: 1) == .openGlobalGraph) + #expect(await daemon.nextCommand(onConnection: 1) == .openProject( + path: "/work/send-before-rejoin")) + try await sendTask.value + + reader.cancel() + await reader.value + } + + @Test + func cancellingAnEventStreamClosesItsReaderBeforeReconnect() async throws { + let daemon = try StubDaemon() + defer { daemon.stop() } + let client = OrchestratorClient.live(socketPath: daemon.socketPath) + + let oldReader = Task { + for await _ in client.connect() {} + } + for _ in 0..<100 where daemon.acceptedConnectionCount == 0 { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(daemon.acceptedConnectionCount == 1) + + oldReader.cancel() + for _ in 0..<100 where !daemon.peerHasClosed(at: 0) { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(daemon.peerHasClosed(at: 0)) + await oldReader.value + + let newReader = Task { + await firstEvent(of: client.connect()) + } + for _ in 0..<100 where daemon.acceptedConnectionCount < 2 { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(daemon.acceptedConnectionCount == 2) + try daemon.reply(.errorOccurred("after cancellation"), onConnection: 1) + #expect(await newReader.value == .errorOccurred("after cancellation")) + newReader.cancel() + } + private func firstEvent(of events: AsyncStream) async -> DaemonEvent? { for await event in events { return event } return nil @@ -163,6 +286,17 @@ private final class StubDaemon: @unchecked Sendable { lock.withLock { acceptedDescriptors.count } } + func peerHasClosed(at index: Int) -> Bool { + guard + let descriptor = lock.withLock({ + acceptedDescriptors.indices.contains(index) ? acceptedDescriptors[index] : nil + }), descriptor >= 0 + else { return true } + var byte: UInt8 = 0 + let result = recv(descriptor, &byte, 1, MSG_PEEK | MSG_DONTWAIT) + return result == 0 + } + /// Reads one framed command off an accepted connection, waiting for the accept to land. /// Blocking reads run off the cooperative pool. func nextCommand(onConnection index: Int = 0) async -> DaemonCommand? { diff --git a/graphcode/Tests/ProjectPathValidationTests.swift b/graphcode/Tests/ProjectPathValidationTests.swift index f4c6723e..c3436c10 100644 --- a/graphcode/Tests/ProjectPathValidationTests.swift +++ b/graphcode/Tests/ProjectPathValidationTests.swift @@ -72,4 +72,20 @@ struct ProjectPathValidationTests { #expect(!ProjectRegistry.isOpenable("/Volumes/External/wd/widget")) #expect(!ProjectRegistry.isWellFormedProjectPath("")) } + + @Test + func windowsDriveAndUNCPathsUsePlatformValidation() { + let paths = WindowsPlatformPaths( + homeDirectory: URL(fileURLWithPath: #"C:\Users\Test User"#, isDirectory: true)) + + #expect( + ProjectRegistry.isWellFormedProjectPath( + #"C:\Projects\GraphCode"#, platformPaths: paths)) + #expect( + ProjectRegistry.isWellFormedProjectPath( + #"\\server\share\GraphCode"#, platformPaths: paths)) + #expect( + !ProjectRegistry.isWellFormedProjectPath( + #"C:\"#, platformPaths: paths)) + } } diff --git a/graphcode/Tests/ProjectPersistenceTests.swift b/graphcode/Tests/ProjectPersistenceTests.swift index 7b6b86a9..dfa9497d 100644 --- a/graphcode/Tests/ProjectPersistenceTests.swift +++ b/graphcode/Tests/ProjectPersistenceTests.swift @@ -106,5 +106,45 @@ struct ProjectPersistenceTests { let recents = persistence.loadRecentProjects() #expect(recents.map(\.path) == [newer.path, older.path]) + #expect(recents.map(\.path) == [newer.path, older.path]) + } + @Test + func loadingALegacyPathDerivedGraphMigratesItToTheSafeKey() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-tests-\(UUID().uuidString)", isDirectory: true) + let persistence = ProjectPersistence(baseDirectory: directory) + let project = ProjectRef(path: "/tmp/legacy-project", name: "legacy-project") + let graph = LoopGraph(project: project, nodes: [LoopNode(title: "Legacy")]) + let legacyURL = directory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("_tmp_legacy-project.json") + try JSONEncoder().encode(graph).write(to: legacyURL) + + #expect(persistence.loadGraph(path: project.path)?.nodes.first?.title == "Legacy") + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + + let files = try FileManager.default.contentsOfDirectory( + at: directory.appendingPathComponent("projects", isDirectory: true), + includingPropertiesForKeys: nil) + #expect(files.count == 1) + #expect(files.first?.lastPathComponent.hasPrefix("v1-") == true) + } + + @Test + func deletingAGraphAlsoRemovesItsLegacyPathDerivedFile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-tests-\(UUID().uuidString)", isDirectory: true) + let persistence = ProjectPersistence(baseDirectory: directory) + let path = "/tmp/legacy-delete" + let legacyURL = directory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("_tmp_legacy-delete.json") + let graph = LoopGraph(project: ProjectRef(path: path, name: "legacy-delete")) + try JSONEncoder().encode(graph).write(to: legacyURL) + + persistence.deleteGraph(path: path) + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(persistence.loadGraph(path: path) == nil) } } diff --git a/graphcode/Tests/ProjectRegistryTests.swift b/graphcode/Tests/ProjectRegistryTests.swift index 1682d7ec..a334f4de 100644 --- a/graphcode/Tests/ProjectRegistryTests.swift +++ b/graphcode/Tests/ProjectRegistryTests.swift @@ -120,6 +120,123 @@ struct ProjectRegistryTests { connectionID: UUID()) } + @Test + func applyReturnsRejectedCommandWithoutASuccessSnapshot() async { + let (registry, persistence) = makeRegistryAndPersistence() + let connectionID = UUID() + await registry.addConnection(id: connectionID, fileDescriptor: -1) + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: connectionID) + + let result = await registry.apply( + .graphCommand( + projectPath: "/tmp/project-a", + command: .createNode(NodeDraft(title: "No goal", loopType: .goalBased))), + connectionID: connectionID) + + #expect(result?.error == "node creation refused: draft is invalid") + #expect(result?.response == nil) + #expect(persistence.loadGraph(path: "/tmp/project-a")?.nodes.isEmpty != false) + } + + @Test + func v2RejectsAnOversizedResultBeforePersistingTheMutation() async { + let (registry, persistence) = makeRegistryAndPersistence() + let transport = RecordingConnection() + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: UUID()) + await registry.addConnection(id: transport.id, channel: channel) + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: transport.id) + + let instruction = String(repeating: "x", count: 30_000) + var applied = 0 + var rejected = false + for index in 0..<50 { + let result = await registry.apply( + .graphCommand( + projectPath: "/tmp/project-a", + command: .createNode( + NodeDraft( + title: "Large \(index)", + loopType: .turnBased, + firstInstruction: instruction))), + connectionID: transport.id) + if result?.error != nil { + rejected = true + #expect(result?.response == nil) + break + } + applied += 1 + } + + #expect(rejected) + #expect(applied > 0) + #expect(persistence.loadGraph(path: "/tmp/project-a")?.nodes.count == applied) + } + + @Test + func v1StillAcceptsACommandWhoseEventExceedsTheV2Limit() async { + let (registry, persistence) = makeRegistryAndPersistence() + let connectionID = UUID() + await registry.addConnection(id: connectionID, fileDescriptor: -1) + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: connectionID) + + let result = await registry.apply( + .graphCommand( + projectPath: "/tmp/project-a", + command: .createNode( + NodeDraft( + title: "Legacy large", + loopType: .turnBased, + firstInstruction: String(repeating: "x", count: 1_100_000)))), + connectionID: connectionID) + + #expect(result?.error == nil) + #expect(persistence.loadGraph(path: "/tmp/project-a")?.nodes.count == 1) + } + + @Test + func concurrentApplyResponsesContainTheSnapshotFromTheirOwnCommand() async { + let (registry, _) = makeRegistryAndPersistence() + let connectionID = UUID() + await registry.addConnection(id: connectionID, fileDescriptor: -1) + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: connectionID) + + async let firstResult = registry.apply( + .graphCommand( + projectPath: "/tmp/project-a", + command: .createNode( + NodeDraft( + title: "First", loopType: .turnBased, checkDescription: "Sound?", + firstInstruction: "Work"))), + connectionID: connectionID) + async let secondResult = registry.apply( + .graphCommand( + projectPath: "/tmp/project-a", + command: .createNode( + NodeDraft( + title: "Second", loopType: .turnBased, checkDescription: "Clear?", + firstInstruction: "Work"))), + connectionID: connectionID) + + let results = [await firstResult, await secondResult].compactMap { result -> LoopGraph? in + guard let response = result?.response, case .graphChanged(let graph) = response else { + Issue.record("expected a correlated graph snapshot for each applied command") + return nil + } + return graph + } + + #expect(results.count == 2) + #expect(results.contains { $0.nodes.count == 1 && $0.nodes.contains { $0.title == "First" } }) + #expect( + results.contains { + $0.nodes.count == 2 + && Set($0.nodes.map(\.title)) == Set(["First", "Second"]) + }) + } + /// The bug this guards: `.openProject` used to detach a connection from whatever /// project it had previously joined before joining the new one, so opening a second /// folder silently stopped the first folder's `graphChanged` broadcasts from ever @@ -234,6 +351,22 @@ struct ProjectRegistryTests { #expect(persistence.loadGraph(path: "/tmp/project-d")?.nodes.count == 1) } + @Test + func forgetProjectReturnsCorrelatedNoPayloadSuccess() async { + let (registry, _) = makeRegistryAndPersistence() + let connectionID = UUID() + await registry.addConnection(id: connectionID, fileDescriptor: -1) + await registry.handle(.openProject(path: "/tmp/project-d"), connectionID: connectionID) + + let result = await registry.apply( + .forgetProject(path: "/tmp/project-d"), + connectionID: connectionID) + + #expect(result?.succeeded == true) + #expect(result?.response == nil) + #expect(result?.error == nil) + } + @Test func deletingAProjectsLoopsDiscardsThemForGood() async { let (registry, persistence) = makeRegistryAndPersistence() @@ -258,6 +391,121 @@ struct ProjectRegistryTests { #expect(persistence.loadGraph(path: "/tmp/project-e")?.nodes.isEmpty != false) } + @Test + func broadcastWriteFailureEvictsTheConnectionAndClosesItsTransport() async throws { + let (registry, _) = makeRegistryAndPersistence() + let connection = FailingConnection() + await registry.addConnection(id: connection.id, connection: connection) + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: connection.id) + + for _ in 0..<100 where !connection.isClosed { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(connection.isClosed) + #expect(connection.sendAttempts == 1) + + await registry.handle(.openProject(path: "/tmp/project-a"), connectionID: connection.id) + #expect(connection.sendAttempts == 1) + } + + @Test + func v2ListRecentProjectsUsesOnlyCorrelatedResponseWithoutReplayEvent() async throws { + let (registry, _) = makeRegistryAndPersistence() + let transport = RecordingConnection() + let replayStore = DaemonReplayStore(capacity: 8) + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: UUID(), + replayStore: replayStore) + await registry.addConnection(id: transport.id, channel: channel) + + let result = await registry.apply(.listRecentProjects, connectionID: transport.id) + #expect(result?.error == nil) + #expect(result?.response != nil) + #expect(transport.frames.isEmpty) + + let requestID = UUID() + try await channel.sendResponse( + requestID: requestID, + event: try #require(result?.response)) + #expect(transport.frames.count == 1) + let response = try JSONDecoder().decode( + DaemonWireEnvelope.self, + from: try #require(transport.frames.first)) + #expect(response.kind == .response) + #expect(response.requestID == requestID) + #expect(response.sequence == nil) + + await registry.removeConnection(transport.id) + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: await channel.clientID, + replayStore: replayStore) + try await reconnectChannel.replay(after: 0) + #expect(reconnectTransport.frames.isEmpty) + } + +#if canImport(Darwin) + @Test + func unixCloseSyncWaitsForActiveFrameBeforeClosingDescriptor() async throws { + try await assertUnixCloseWaitsForActiveFrame { connection in + connection.closeSync() + } + } + + @Test + func unixAsyncCloseWaitsForActiveFrameBeforeClosingDescriptor() async throws { + try await assertUnixCloseWaitsForActiveFrame { connection in + try await connection.close() + } + } + + private func assertUnixCloseWaitsForActiveFrame( + _ close: @escaping @Sendable (UnixSocketConnection) async throws -> Void + ) async throws { + var pair = [Int32](repeating: -1, count: 2) + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + let peerDescriptor = pair[1] + defer { Darwin.close(peerDescriptor) } + + var sendBuffer: Int32 = 1_024 + _ = setsockopt( + pair[0], + SOL_SOCKET, + SO_SNDBUF, + &sendBuffer, + socklen_t(MemoryLayout.size)) + let connection = UnixSocketConnection( + fileDescriptor: pair[0], writeTimeout: 5) + let payload = Data(repeating: 0x41, count: 2 * 1024 * 1024) + let sendTask = Task { + try await connection.sendFrame(payload) + } + try await Task.sleep(for: .milliseconds(50)) + + let closeCompletion = CloseCompletionProbe() + let closeTask = Task { + try await close(connection) + await closeCompletion.mark() + } + try await Task.sleep(for: .milliseconds(50)) + let closedBeforeDrain = await closeCompletion.completed + #expect(!closedBeforeDrain) + + let received = try await Task.detached { + try FramedMessageIO.readFrame(from: peerDescriptor) + }.value + try await sendTask.value + try await closeTask.value + let closedAfterDrain = await closeCompletion.completed + #expect(closedAfterDrain) + #expect(received == payload) + } +#endif + @Test func deletingAProjectsLoopsEndsEverySessionFirst() async { // The graph is the only handle on the loops' detached sessions — deleting it with @@ -427,6 +675,59 @@ struct ProjectRegistryTests { } } +private final class FailingConnection: @unchecked Sendable, DaemonConnection { + let id = UUID() + let endpoint: DaemonEndpoint = .namedPipe("failing") + private let lock = NSLock() + private var closed = false + private var attempts = 0 + + var isClosed: Bool { + lock.withLock { closed } + } + + var sendAttempts: Int { + lock.withLock { attempts } + } + + func receiveFrame() async throws -> Data { + throw FramedMessageIO.IOError.connectionClosed + } + + func sendFrame(_ data: Data) async throws { + lock.withLock { attempts += 1 } + throw FramedMessageIO.IOError.writeFailed(errno: 1) + } + + func close() async throws { + lock.withLock { closed = true } + } +} + +private final class RecordingConnection: @unchecked Sendable, DaemonConnection { + let id = UUID() + let endpoint: DaemonEndpoint = .namedPipe("recording") + private(set) var frames = [Data]() + + func receiveFrame() async throws -> Data { + throw FramedMessageIO.IOError.connectionClosed + } + + func sendFrame(_ data: Data) async throws { + frames.append(data) + } + + func close() async throws {} +} + +private actor CloseCompletionProbe { + private(set) var completed = false + + func mark() { + completed = true + } +} + /// Whether anything at all is waiting to be read — how "this socket was told nothing /// more" is asserted, without a timeout that would make the test slow when it passes. private func hasPendingBytes(_ fileDescriptor: Int32) -> Bool { diff --git a/graphcode/Tests/RemoteRepositoryTests.swift b/graphcode/Tests/RemoteRepositoryTests.swift index 318310da..51a86c00 100644 --- a/graphcode/Tests/RemoteRepositoryTests.swift +++ b/graphcode/Tests/RemoteRepositoryTests.swift @@ -24,6 +24,19 @@ struct RemoteRepositoryTests { #expect(RemoteProjectLocation.parse(projectPath: path) == location) } + @Test + func projectPathPercentEncodesSpecialUnicodeAndIPv6Authorities() { + let special = RemoteProjectLocation( + user: "dev", host: "2001:db8::1", port: 2200, remotePath: "/repo name/#q?x%雪") + #expect( + special.projectPath + == "ssh://dev@[2001:db8::1]:2200/repo%20name/%23q%3Fx%25%E9%9B%AA") + #expect(RemoteProjectLocation.parse(projectPath: special.projectPath) == special) + let components = URLComponents(string: special.projectPath) + #expect(components?.host == "2001:db8::1") + #expect(components?.path == "/repo name/#q?x%雪") + } + @Test func onlyRealRemotePathsParse() { // Local folders, the global graph, and junk all take the local branch. diff --git a/graphcode/Tests/RemoteSessionLaunchTests.swift b/graphcode/Tests/RemoteSessionLaunchTests.swift index 72398ffd..962b16d3 100644 --- a/graphcode/Tests/RemoteSessionLaunchTests.swift +++ b/graphcode/Tests/RemoteSessionLaunchTests.swift @@ -225,6 +225,110 @@ struct RemoteSessionLaunchTests { #expect(deliveredPaths(in: unbriefed) == ["~/.graphcode/bin/graphcode"]) } + @Test + func windowsClientToMacOSRemoteDeliversBridgeStateToTheShim() throws { + let state = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 4, + port: 45_678, + capability: String(repeating: "a", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + let delivery = try #require( + ZmxSessionLauncher.remoteDeliveryScript( + forNode: nil, at: location, settings: GraphcodeSettings(), bridgeState: state)) + #expect(!deliveredPaths(in: delivery).contains("~/.graphcode/bridge-state.json")) + let transfer = try #require( + ZmxSessionLauncher.remoteBridgeStateTransfer(state, at: location)) + let transferCommand = transfer.invocation.joined(separator: " ") + #expect(transferCommand.contains("bridge-state.json")) + #expect(transferCommand.contains("bridge-state-generation")) + #expect(!transferCommand.contains(state.capability)) + #expect(String(data: transfer.input, encoding: .utf8)?.contains(state.capability) == true) + let ensure = try #require( + ZmxSessionLauncher.remoteEnsureInvocation( + forNode: LoopNode( + title: "Bridge", loopType: .goalBased, goal: GoalSpec(summary: "bridge")), + at: location, settings: GraphcodeSettings(), bridgeState: state)) + let ensureCommand = ensure.joined(separator: " ") + #expect(ensureCommand.contains("bridge-state-generation")) + #expect(ensureCommand.contains("4")) + #expect(!ensureCommand.contains(state.capability)) + } + + @Test + func outOfOrderSameAuthorityStateTransferKeepsTheNewerGeneration() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-bridge-order-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let newer = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 9, + port: 45_678, + capability: String(repeating: "9", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + let older = RemoteBridgeWireState( + daemonInstanceID: newer.daemonInstanceID, + generation: 8, + port: 45_678, + capability: String(repeating: "8", count: 64), + issuedAt: 1_699_999_900, + expiresAt: 1_700_000_900) + + func transfer(_ state: RemoteBridgeWireState) throws { + let data = try JSONEncoder().encode(state) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [ + "-c", + RemoteGraphAccess.bridgeStateInstallerScript( + length: data.count, sha256: GraphcodeSHA256.hex(data)), + ] + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = root.path + process.environment = environment + let input = Pipe() + process.standardInput = input + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + input.fileHandleForWriting.write(data) + input.fileHandleForWriting.closeFile() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } + + try transfer(newer) + try transfer(older) + let stateURL = root.appendingPathComponent(".graphcode/bridge-state.json") + let generationURL = root.appendingPathComponent( + ".graphcode/bridge-state-generation") + let installed = try JSONDecoder().decode( + RemoteBridgeWireState.self, from: Data(contentsOf: stateURL)) + #expect(installed == newer) + #expect(try String(contentsOf: generationURL, encoding: .ascii) == "9") + } + + @Test + func windowsClientToMacOSRemoteTriesBridgeBeforeUnixFallback() { + let shim = RemoteGraphAccess.cliShimSource + #expect(shim.contains("if os.path.exists(state_path):")) + #expect(shim.contains("state = read_bridge_state(state_path)")) + #expect(shim.contains("if os.name != \"nt\":")) + #expect(!shim.contains("sys.platform == \"darwin\" and os.path.exists(state_path)")) + #expect(shim.contains("socket.AF_UNIX")) + } + + @Test + func macOSClientToMacOSRemoteSupersedesBridgeBeforeUnixForwarding() { + let script = RemoteSocketForwarder.forwardScript( + for: location, localSocketPath: "/Users/dev/.graphcode/graphcoded.sock") + #expect(script.contains("bridge-state.json")) + #expect(script.contains("bridge-state-generation")) + #expect(script.contains("graphcoded.sock")) + } + /// Decodes the installer fragment's base64 JSON manifest back into the delivered /// paths — asserting on what actually lands rather than on encoding details. private func deliveredPaths(in script: String) -> [String] { diff --git a/graphcode/Tests/SupportDirectoryTests.swift b/graphcode/Tests/SupportDirectoryTests.swift index cb8d587b..8d673488 100644 --- a/graphcode/Tests/SupportDirectoryTests.swift +++ b/graphcode/Tests/SupportDirectoryTests.swift @@ -26,6 +26,19 @@ struct SupportDirectoryTests { #expect(SupportDirectory.binDirectory.deletingLastPathComponent() == root) } + @Test + func aBackslashOverrideRemainsRelativeOnDarwin() { + let home = URL(fileURLWithPath: "/Users/test-user", isDirectory: true) + let root = SupportDirectory.url( + environment: [SupportDirectory.environmentKey: #"\relative-state"#], + homeDirectory: home) + #if os(Windows) + #expect(root.path != home.appendingPathComponent(#"\relative-state"#).path) + #else + #expect(root.path == home.appendingPathComponent(#"\relative-state"#).path) + #endif + } + @Test func theSocketPathFitsInSunPathWithRoomToSpare() { // `sockaddr_un.sun_path` is a hard 104 bytes on Darwin, and a bind that overflows it diff --git a/graphcoded/Sources/main.swift b/graphcoded/Sources/main.swift index b1b934be..3332a29f 100644 --- a/graphcoded/Sources/main.swift +++ b/graphcoded/Sources/main.swift @@ -1,181 +1,689 @@ import Foundation import GraphcodeKit - -#if canImport(Darwin) - import Darwin +#if os(Windows) + import WinSDK #endif -// graphcoded — the graphcode orchestrator daemon. -// -// From Phase 3 on this is no longer an empty skeleton (see docs/07-roadmap.md): it -// speaks `DaemonProtocol` over the Unix socket, fires `.handoff` edges automatically, -// and arms time-based triggers that keep firing whether or not `graphcode.app` is -// running — see docs/03-architecture.md#why-a-daemon-at-all for why this has to be a -// separate, long-lived process rather than in-app state. From Phase 4 on it hosts one -// `LoopGraph` per opened project (`ProjectRegistry`, wrapping one `GraphStore` per -// project) rather than a single hardcoded graph, each persisted under this directory. - -let fileManager = FileManager.default - -// Launched by an agent rather than launchd — `make daemon-install` from a loop's own -// shell — the daemon inherits that session's identity and would hand it to every backend -// it starts. See `AgentEnvironment`. -AgentEnvironment.scrubInheritedAgentIdentity() - -// Migrates a pre-existing `~/Library/Application Support/graphcode` and creates the -// directory. Has to happen before anything reads or writes — including the socket bind -// immediately below. -SupportDirectory.prepare() -let supportDirectory = SupportDirectory.url - -let socketURL = DaemonSocketPath.url -// Clear a stale socket file left behind by a previous run that didn't shut down cleanly. -try? fileManager.removeItem(at: socketURL) - -func fail(_ message: String) -> Never { - FileHandle.standardError.write(Data("graphcoded: \(message)\n".utf8)) - exit(1) -} - -let socketDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) -guard socketDescriptor >= 0 else { - fail("failed to create socket (errno \(errno))") -} - -var address = sockaddr_un() -address.sun_family = sa_family_t(AF_UNIX) -address.sun_len = UInt8(MemoryLayout.size) - -let path = socketURL.path -withUnsafeMutablePointer(to: &address.sun_path) { pathField in - pathField.withMemoryRebound( - to: CChar.self, capacity: MemoryLayout.size(ofValue: pathField.pointee) - ) { pathPointer in - _ = path.withCString { cPath in - strncpy(pathPointer, cPath, MemoryLayout.size(ofValue: pathField.pointee) - 1) - } - } -} - -let bindResult = withUnsafePointer(to: &address) { addressPointer -> Int32 in - addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rawPointer in - bind(socketDescriptor, rawPointer, socklen_t(MemoryLayout.size)) - } -} -guard bindResult == 0 else { - fail("failed to bind \(path) (errno \(errno))") -} - -guard listen(socketDescriptor, 8) == 0 else { - fail("failed to listen on \(path) (errno \(errno))") -} - -FileHandle.standardOutput.write(Data("graphcoded: listening on \(path)\n".utf8)) - -// A broadcast writes to every connected client, and a client can vanish without a -// clean close — the app killed, a CLI exiting early, a pane crashing. The write then -// raises SIGPIPE, whose default action *terminates the daemon*: observed as exit -// status -13 in `launchctl list`, with every loop's in-flight send failing until -// launchd restarted the process seconds later. -// -// Ignoring it turns that into what the code already handles correctly: -// `FramedMessageIO.writeAll` sees write() return -1/EPIPE, throws, and -// `GraphStore.send` drops the dead connection. The error path was always right; the -// process just never lived long enough to run it. -signal(SIGPIPE, SIG_IGN) - -// Termination is handled on the main queue, not in signal context (#167). The handlers -// this replaces called `exit(0)` from inside the signal handler itself, and `exit` is -// not async-signal-safe: it runs atexit and runtime teardown after interrupting whatever -// thread happened to be running — which can be a thread mid-`malloc` or mid-`write`, -// holding exactly the locks teardown needs. An idle daemon died cleanly in milliseconds -// every time; a busy one could deadlock until launchd's ExitTimeOut escalated to -// SIGKILL, observed as `launchctl bootout` taking ~29 seconds while a workspace was -// being deleted. A dispatch signal source delivers the signal as an ordinary work item, -// where `exit` is just a function call. -// -// `SIG_IGN` first, so the default terminate-without-cleanup disposition can't win the -// race before the sources are resumed. -signal(SIGTERM, SIG_IGN) -signal(SIGINT, SIG_IGN) - -func makeShutdownSource(for signalNumber: Int32) -> DispatchSourceSignal { - let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) - source.setEventHandler { - unlink(path) - exit(0) - } - source.resume() - return source -} - -// Top-level lets, so the sources outlive this file's execution — a released source -// stops delivering, and the signal falls back to the ignored disposition above, which -// would make the daemon *unkillable* by SIGTERM instead of slow. -let terminateSource = makeShutdownSource(for: SIGTERM) -let interruptSource = makeShutdownSource(for: SIGINT) - -let registry = ProjectRegistry(persistenceDirectory: supportDirectory) - -/// Bridges a blocking socket read onto a background queue so the `Task` awaiting it -/// never blocks Swift concurrency's cooperative thread pool — the whole connection -/// handler below is otherwise just async/await hops (this, plus actor calls). -@Sendable func readFrameAsync(from fileDescriptor: Int32) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global().async { +#if os(Windows) + private enum DaemonStartupHandoffError: Error { + case missingReadyEvent + case openStartupEvent(UInt32) + case waitStartupEvent(DWORD) + case openReadyEvent(UInt32) + } + + private final class DaemonStartupHandoff: @unchecked Sendable { + let startupEvent: HANDLE? + let readyEvent: HANDLE? + + init(environment: [String: String] = ProcessInfo.processInfo.environment) throws { + guard let startupEventName = environment["GRAPHCODE_DAEMON_STARTUP_EVENT"] else { + startupEvent = nil + readyEvent = nil + return + } + var startupName = Array(startupEventName.utf16) + startupName.append(0) + guard + let startupEvent = startupName.withUnsafeBufferPointer({ + OpenEventW(DWORD(SYNCHRONIZE), false, $0.baseAddress) + }) + else { + throw DaemonStartupHandoffError.openStartupEvent(GetLastError()) + } + let startupResult = WaitForSingleObject(startupEvent, 5_000) + guard startupResult == WAIT_OBJECT_0 else { + CloseHandle(startupEvent) + throw DaemonStartupHandoffError.waitStartupEvent(startupResult) + } + + guard let readyEventName = environment["GRAPHCODE_DAEMON_HANDOFF_READY_EVENT"] else { + CloseHandle(startupEvent) + throw DaemonStartupHandoffError.missingReadyEvent + } + var readyName = Array(readyEventName.utf16) + readyName.append(0) + guard + let readyEvent = readyName.withUnsafeBufferPointer({ + OpenEventW(DWORD(EVENT_MODIFY_STATE), false, $0.baseAddress) + }) + else { + let code = GetLastError() + CloseHandle(startupEvent) + throw DaemonStartupHandoffError.openReadyEvent(code) + } + self.startupEvent = startupEvent + self.readyEvent = readyEvent + } + + var isParentHandoff: Bool { startupEvent != nil } + + func publish() -> Bool { + guard let readyEvent else { return true } + return SetEvent(readyEvent) + } + + deinit { + if let startupEvent { + CloseHandle(startupEvent) + } + if let readyEvent { + CloseHandle(readyEvent) + } + } + } + + private func writeDaemonHandoffTestState(_ state: String) { + guard let path = ProcessInfo.processInfo.environment["GRAPHCODE_DAEMON_HANDOFF_TEST_STATE"] + else { return } + try? Data(state.utf8).write(to: URL(fileURLWithPath: path), options: .atomic) + } + + SupportDirectory.prepare() + private let startupHandoff: DaemonStartupHandoff = { + do { + return try DaemonStartupHandoff() + } catch { + FileHandle.standardError.write(Data("graphcoded: \(error)\n".utf8)) + exit(1) + } + }() + if startupHandoff.isParentHandoff { + writeDaemonHandoffTestState("startup-gate") + } + let endpointName: String = { + do { + return try WindowsNamedPipeEndpoint.name() + } catch { + writeDaemonHandoffTestState("failed: \(error)") + FileHandle.standardError.write(Data("graphcoded: \(error)\n".utf8)) + exit(1) + } + }() + + let instanceLock: WindowsDaemonInstanceLock = { + do { + let startupReservation: WindowsDaemonStartupReservation? + if startupHandoff.isParentHandoff { + startupReservation = nil + } else { + startupReservation = try WindowsDaemonStartupReservation() + } + defer { _ = startupReservation } + try WindowsNamedPipeEndpoint.recordActiveGeneration() + return try WindowsDaemonInstanceLock() + } catch WindowsPipeError.instanceAlreadyRunning { + writeDaemonHandoffTestState("failed: instance already running") + FileHandle.standardError.write(Data("graphcoded: daemon is already running\n".utf8)) + exit(1) + } catch { + writeDaemonHandoffTestState("failed: \(error)") + FileHandle.standardError.write(Data("graphcoded: \(error)\n".utf8)) + exit(1) + } + }() + if startupHandoff.isParentHandoff { + writeDaemonHandoffTestState("lifetime-lock") + } + let supportDirectory = SupportDirectory.url + let replayStore = DaemonReplayStore(capacity: 128) + let handshakeLimiter = WindowsPipeHandshakeLimiter(limit: 32) + let registry = ProjectRegistry( + persistenceDirectory: supportDirectory, + replayStore: replayStore) + let replayCleanupTask = replayStore.startCleanup() + let listener: WindowsNamedPipeListener = { + do { + return try WindowsNamedPipeListener( + pipeName: endpointName, + onPublished: { + writeDaemonHandoffTestState( + startupHandoff.publish() ? "published" : "failed: ready event") + }) + } catch { + writeDaemonHandoffTestState("failed: \(error)") + FileHandle.standardError.write(Data("graphcoded: \(error)\n".utf8)) + exit(1) + } + }() + + final class ShutdownState: @unchecked Sendable { + let lock = NSLock() + var stopped = false + + func isStopped() -> Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + } + let shutdown = ShutdownState() + + #if os(Windows) + if let shutdownEventName = ProcessInfo.processInfo.environment[ + "GRAPHCODE_DAEMON_SHUTDOWN_EVENT" + ] { + var wideName = Array(shutdownEventName.utf16) + wideName.append(0) + if let shutdownEvent = wideName.withUnsafeBufferPointer({ + OpenEventW(DWORD(SYNCHRONIZE), false, $0.baseAddress) + }) { + DispatchQueue.global(qos: .utility).async { + _ = WaitForSingleObject(shutdownEvent, INFINITE) + shutdown.lock.lock() + shutdown.stopped = true + shutdown.lock.unlock() + Task { + await ZmxSessionLauncher.shutdownWindowsRemoteBridge() + try? await listener.close() + CloseHandle(shutdownEvent) + exit(0) + } + } + } + } + #endif + + func handleWindowsConnection(_ connection: any DaemonConnection) { + guard let handshakePermit = handshakeLimiter.tryAcquire() else { + Task { try? await connection.close() } + return + } + Task { + let connectionID = connection.id + var channel: DaemonConnectionChannel? + var initialFrameData: Data? + FileHandle.standardOutput.write(Data("graphcoded: client connected\n".utf8)) + defer { + let hadChannel = channel != nil + Task { + await registry.removeConnection(connectionID) + if !hadChannel { try? await connection.close() } + } + } + do { - continuation.resume(returning: try FramedMessageIO.readFrame(from: fileDescriptor)) + let firstData: Data + if let pipe = connection as? WindowsNamedPipeConnection { + firstData = try await pipe.receiveFrameWithFirstByteDeadline() + } else { + firstData = try await connection.receiveFrame() + } + initialFrameData = firstData + switch try DaemonWireProtocol.decodeClientFrame(firstData) { + case .v1(let command): + let v1Channel = DaemonConnectionChannel(connection: connection, mode: .v1) + channel = v1Channel + await registry.addConnection(id: connectionID, channel: v1Channel) + handshakePermit.release() + await registry.handle(command, connectionID: connectionID) + + case .v2(let hello): + guard hello.kind == .hello else { + try await connection.sendFrame( + JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, code: DaemonWireErrorCode.expectedHello.rawValue, + message: "the first v2 frame must be hello"))) + return + } + let selectedVersion: Int + do { + selectedVersion = try DaemonWireProtocol.negotiatedVersion(for: hello) + } catch DaemonWireProtocol.NegotiationError.noSupportedVersion { + try await connection.sendFrame( + JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, code: DaemonWireErrorCode.unsupportedVersion.rawValue, + message: "no mutually supported daemon protocol version"))) + return + } + let mode: DaemonProtocolMode = selectedVersion == 2 ? .v2(version: 2) : .v1 + let v2Channel = DaemonConnectionChannel( + connection: connection, + mode: mode, + clientID: hello.clientID ?? connectionID, + subscription: hello.subscription, + replayStore: replayStore) + channel = v2Channel + await registry.addConnection(id: connectionID, channel: v2Channel) + try await v2Channel.sendHelloResponse(selectedVersion: selectedVersion) + handshakePermit.release() + if selectedVersion == 2, let resumeFrom = hello.resumeFrom { + do { + try await v2Channel.replay(after: resumeFrom) + } catch DaemonConnectionChannelError.replayUnavailable { + try await v2Channel.sendError( + code: .replayUnavailable, + message: "requested replay history is unavailable") + } catch DaemonConnectionChannelError.cursorOutsideWindow { + try await v2Channel.sendError( + code: .cursorOutsideWindow, + message: "requested cursor is beyond the retained event history") + } + } + } + + guard let channel else { return } + while true { + let data: Data + if let pipe = connection as? WindowsNamedPipeConnection { + data = try await pipe.receiveFrameWithPostHandshakeDeadline() + } else { + data = try await channel.receiveFrame() + } + do { + switch try DaemonWireProtocol.decodeClientFrame(data) { + case .v1(let command): + guard channel.mode == .v1 else { + try await channel.sendError( + code: .malformedEnvelope, + message: "v2 connections must send request envelopes") + continue + } + await registry.handle(command, connectionID: connectionID) + + case .v2(let request): + guard case .v2 = channel.mode, request.kind == .request, + let requestID = request.requestID, let command = request.command + else { + try await channel.sendError( + requestID: DaemonWireProtocol.requestIDIfPresent(in: data), + code: .malformedEnvelope, + message: "expected a v2 request envelope") + continue + } + guard let result = await registry.apply(command, connectionID: connectionID) else { + try await channel.sendError( + requestID: requestID, + code: .connectionClosed, + message: "connection is no longer registered") + continue + } + if let error = result.error { + try await channel.sendError( + requestID: requestID, code: .requestFailed, message: error) + } else if let response = result.response { + try await channel.sendResponse(requestID: requestID, event: response) + } else if result.succeeded { + try await channel.sendSuccess(requestID: requestID) + } else { + try await channel.sendError( + requestID: requestID, + code: .requestFailed, + message: "request could not be applied") + } + } + } catch { + try await channel.sendError( + requestID: DaemonWireProtocol.requestIDIfPresent(in: data), + code: .malformedEnvelope, + message: "\(error)") + } + } + } catch WindowsPipeError.timedOut { + try? await connection.close() } catch { - continuation.resume(throwing: error) + if let channel { + try? await channel.sendError(code: .transportFailure, message: "\(error)") + } else if let initialFrameData, + let errorFrame = try? DaemonWireProtocol.initialErrorFrame( + for: initialFrameData, message: "\(error)") + { + try? await connection.sendFrame(errorFrame) + } } + FileHandle.standardOutput.write(Data("graphcoded: client disconnected\n".utf8)) } } -} -func handleConnection(_ fileDescriptor: Int32) { Task { - let connectionID = UUID() - await registry.addConnection(id: connectionID, fileDescriptor: fileDescriptor) - FileHandle.standardOutput.write(Data("graphcoded: client connected\n".utf8)) - while true { - let data: Data + while !Task.isCancelled { do { - data = try await readFrameAsync(from: fileDescriptor) + handleWindowsConnection(try await listener.accept()) } catch { - break + if shutdown.isStopped() { return } } + } + } + + signal(SIGINT) { _ in + shutdown.lock.lock() + shutdown.stopped = true + shutdown.lock.unlock() + Task { + await ZmxSessionLauncher.shutdownWindowsRemoteBridge() + try? await listener.close() + exit(0) + } + } + signal(SIGTERM) { _ in + shutdown.lock.lock() + shutdown.stopped = true + shutdown.lock.unlock() + Task { + await ZmxSessionLauncher.shutdownWindowsRemoteBridge() + try? await listener.close() + exit(0) + } + } + + FileHandle.standardOutput.write( + Data("graphcoded: listening on \(String(describing: listener.endpoint))\n".utf8)) + withExtendedLifetime((replayCleanupTask, instanceLock)) { + dispatchMain() + } +#endif + +// Both platform daemons live in this one file because only `main.swift` may carry +// top-level code: a second file holding the Darwin entry point compiles on Windows, +// where it is excluded, and fails everywhere else. + +#if !os(Windows) + #if canImport(Darwin) + import Darwin + #endif + + // graphcoded — the graphcode orchestrator daemon. + // + // From Phase 3 on this is no longer an empty skeleton (see docs/07-roadmap.md): it + // speaks `DaemonProtocol` over the Unix socket, fires `.handoff` edges automatically, + // and arms time-based triggers that keep firing whether or not `graphcode.app` is + // running — see docs/03-architecture.md#why-a-daemon-at-all for why this has to be a + // separate, long-lived process rather than in-app state. From Phase 4 on it hosts one + // `LoopGraph` per opened project (`ProjectRegistry`, wrapping one `GraphStore` per + // project) rather than a single hardcoded graph, each persisted under this directory. + + let fileManager = FileManager.default + + // Launched by an agent rather than launchd — `make daemon-install` from a loop's own + // shell — the daemon inherits that session's identity and would hand it to every backend + // it starts. See `AgentEnvironment`. + AgentEnvironment.scrubInheritedAgentIdentity() + + // Migrates a pre-existing `~/Library/Application Support/graphcode` and creates the + // directory. Has to happen before anything reads or writes — including the socket bind + // immediately below. + SupportDirectory.prepare() + let supportDirectory = SupportDirectory.url + + let socketURL = DaemonSocketPath.url + // Clear a stale socket file left behind by a previous run that didn't shut down cleanly. + try? fileManager.removeItem(at: socketURL) + + func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("graphcoded: \(message)\n".utf8)) + exit(1) + } + + let socketDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketDescriptor >= 0 else { + fail("failed to create socket (errno \(errno))") + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + + let path = socketURL.path + withUnsafeMutablePointer(to: &address.sun_path) { pathField in + pathField.withMemoryRebound( + to: CChar.self, capacity: MemoryLayout.size(ofValue: pathField.pointee) + ) { pathPointer in + _ = path.withCString { cPath in + strncpy(pathPointer, cPath, MemoryLayout.size(ofValue: pathField.pointee) - 1) + } + } + } + + let bindResult = withUnsafePointer(to: &address) { addressPointer -> Int32 in + addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rawPointer in + bind(socketDescriptor, rawPointer, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + fail("failed to bind \(path) (errno \(errno))") + } + + guard listen(socketDescriptor, 8) == 0 else { + fail("failed to listen on \(path) (errno \(errno))") + } + + FileHandle.standardOutput.write(Data("graphcoded: listening on \(path)\n".utf8)) + + // A broadcast writes to every connected client, and a client can vanish without a + // clean close — the app killed, a CLI exiting early, a pane crashing. The write then + // raises SIGPIPE, whose default action *terminates the daemon*: observed as exit + // status -13 in `launchctl list`, with every loop's in-flight send failing until + // launchd restarted the process seconds later. + // + // Ignoring it turns that into what the code already handles correctly: + // `FramedMessageIO.writeAll` sees write() return -1/EPIPE, throws, and + // `GraphStore.send` drops the dead connection. The error path was always right; the + // process just never lived long enough to run it. + signal(SIGPIPE, SIG_IGN) + + // Termination is handled on the main queue, not in signal context (#167). The handlers + // this replaces called `exit(0)` from inside the signal handler itself, and `exit` is + // not async-signal-safe: it runs atexit and runtime teardown after interrupting whatever + // thread happened to be running — which can be a thread mid-`malloc` or mid-`write`, + // holding exactly the locks teardown needs. An idle daemon died cleanly in milliseconds + // every time; a busy one could deadlock until launchd's ExitTimeOut escalated to + // SIGKILL, observed as `launchctl bootout` taking ~29 seconds while a workspace was + // being deleted. A dispatch signal source delivers the signal as an ordinary work item, + // where `exit` is just a function call. + // + // `SIG_IGN` first, so the default terminate-without-cleanup disposition can't win the + // race before the sources are resumed. + signal(SIGTERM, SIG_IGN) + signal(SIGINT, SIG_IGN) + + func makeShutdownSource(for signalNumber: Int32) -> DispatchSourceSignal { + let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) + source.setEventHandler { + unlink(path) + exit(0) + } + source.resume() + return source + } + + // Top-level lets, so the sources outlive this file's execution — a released source + // stops delivering, and the signal falls back to the ignored disposition above, which + // would make the daemon *unkillable* by SIGTERM instead of slow. + let terminateSource = makeShutdownSource(for: SIGTERM) + let interruptSource = makeShutdownSource(for: SIGINT) + + let replayStore = DaemonReplayStore(capacity: 128) + let registry = ProjectRegistry( + persistenceDirectory: supportDirectory, + replayStore: replayStore) + let replayCleanupTask = replayStore.startCleanup() + + func handleConnection(_ connection: any DaemonConnection) { + Task { + let connectionID = connection.id + var channel: DaemonConnectionChannel? + FileHandle.standardOutput.write(Data("graphcoded: client connected\n".utf8)) + defer { + let hadChannel = channel != nil + Task { + await registry.removeConnection(connectionID) + if !hadChannel { + try? await connection.close() + } + } + } + + var initialFrameData: Data? do { - let command = try JSONDecoder().decode(DaemonCommand.self, from: data) - await registry.handle(command, connectionID: connectionID) + let firstData = try await connection.receiveFrame() + initialFrameData = firstData + (connection as? UnixSocketConnection)?.setReadTimeout(nil) + switch try DaemonWireProtocol.decodeClientFrame(firstData) { + case .v1(let command): + let v1Channel = DaemonConnectionChannel(connection: connection, mode: .v1) + channel = v1Channel + await registry.addConnection(id: connectionID, channel: v1Channel) + await registry.handle(command, connectionID: connectionID) + + case .v2(let hello): + guard hello.kind == .hello else { + try await connection.sendFrame( + JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, code: DaemonWireErrorCode.expectedHello.rawValue, + message: "the first v2 frame must be hello"))) + return + } + let selectedVersion: Int + do { + selectedVersion = try DaemonWireProtocol.negotiatedVersion(for: hello) + } catch DaemonWireProtocol.NegotiationError.noSupportedVersion { + try await connection.sendFrame( + JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, code: DaemonWireErrorCode.unsupportedVersion.rawValue, + message: "no mutually supported daemon protocol version"))) + return + } + let negotiated: DaemonProtocolMode = + selectedVersion == 2 ? .v2(version: 2) : .v1 + let v2Channel = DaemonConnectionChannel( + connection: connection, + mode: negotiated, + clientID: hello.clientID ?? connectionID, + subscription: hello.subscription, + replayStore: replayStore) + channel = v2Channel + await registry.addConnection(id: connectionID, channel: v2Channel) + try await v2Channel.sendHelloResponse(selectedVersion: selectedVersion) + if selectedVersion == 2, let resumeFrom = hello.resumeFrom { + do { + try await v2Channel.replay(after: resumeFrom) + } catch DaemonConnectionChannelError.replayUnavailable { + try await v2Channel.sendError( + code: .replayUnavailable, + message: "requested replay history is unavailable") + } catch DaemonConnectionChannelError.cursorOutsideWindow { + try await v2Channel.sendError( + code: .cursorOutsideWindow, + message: "requested cursor is beyond the retained event history") + } + } + } + + guard let channel else { return } + while true { + let data: Data + if let unixConnection = connection as? UnixSocketConnection { + data = try await unixConnection.receiveFrameWithPostHandshakeDeadline() + } else { + data = try await channel.receiveFrame() + } + do { + switch try DaemonWireProtocol.decodeClientFrame(data) { + case .v1(let command): + guard channel.mode == .v1 else { + try await channel.sendError( + code: .malformedEnvelope, + message: "v2 connections must send request envelopes") + continue + } + await registry.handle(command, connectionID: connectionID) + + case .v2(let request): + guard case .v2 = channel.mode, request.kind == .request, + let requestID = request.requestID, let command = request.command + else { + try await channel.sendError( + requestID: DaemonWireProtocol.requestIDIfPresent(in: data), + code: .malformedEnvelope, + message: "expected a v2 request envelope") + continue + } + guard let result = await registry.apply(command, connectionID: connectionID) else { + try await channel.sendError( + requestID: requestID, + code: .connectionClosed, + message: "connection is no longer registered") + continue + } + if let error = result.error { + try await channel.sendError( + requestID: requestID, + code: .requestFailed, + message: error) + } else if let response = result.response { + try await channel.sendResponse(requestID: requestID, event: response) + } else if result.succeeded { + // Some mutations intentionally have no payload. A correlated success + // envelope completes the request without manufacturing an error. + try await channel.sendSuccess(requestID: requestID) + } else { + try await channel.sendError( + requestID: requestID, + code: .requestFailed, + message: "request could not be applied") + } + } + } catch { + try await channel.sendError( + requestID: DaemonWireProtocol.requestIDIfPresent(in: data), + code: .malformedEnvelope, + message: "\(error)") + } + } } catch { - // A frame that read fine but didn't decode is version skew, not a dead socket: - // a newer CLI sent a command this daemon predates. Dropping the connection here - // failed *silently* — the client just saw a hang-up — so answer instead and - // keep serving the commands this daemon does understand. - let event = DaemonEvent.errorOccurred( - "unrecognized command — graphcoded may be older than the client that sent it") - if let encoded = try? JSONEncoder().encode(event) { - try? FramedMessageIO.writeFrame(encoded, to: fileDescriptor) + // Transport/framing failures close the socket. Per-frame envelope failures are + // handled inside the loop so one malformed request does not strand a client. + let readDeadlineExpired: Bool + if case FramedMessageIO.IOError.readFailed(let code) = error { + readDeadlineExpired = code == EAGAIN || code == EWOULDBLOCK + } else { + readDeadlineExpired = false + } + if readDeadlineExpired { + try? await connection.close() + } else if let channel { + try? await channel.sendError(code: .transportFailure, message: "\(error)") + } else if let initialFrameData, + let errorFrame = try? DaemonWireProtocol.initialErrorFrame( + for: initialFrameData, message: "\(error)") + { + try? await connection.sendFrame(errorFrame) + } else { + try? await connection.sendFrame( + JSONEncoder().encode( + DaemonWireEnvelope.error( + id: nil, + code: DaemonWireErrorCode.unsupportedVersion.rawValue, + message: "unsupported or malformed initial protocol frame: \(error)"))) } } + FileHandle.standardOutput.write(Data("graphcoded: client disconnected\n".utf8)) } - await registry.removeConnection(connectionID) - close(fileDescriptor) - FileHandle.standardOutput.write(Data("graphcoded: client disconnected\n".utf8)) } -} -DispatchQueue.global().async { - while true { - let clientDescriptor = accept(socketDescriptor, nil, nil) - guard clientDescriptor >= 0 else { continue } - // Belt and braces beside the process-wide ignore above: this socket raises no - // SIGPIPE whatever any library does to the signal disposition later. - var noSignal: Int32 = 1 - setsockopt( - clientDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) - handleConnection(clientDescriptor) + DispatchQueue.global().async { + while true { + let clientDescriptor = accept(socketDescriptor, nil, nil) + guard clientDescriptor >= 0 else { continue } + // Belt and braces beside the process-wide ignore above: this socket raises no + // SIGPIPE whatever any library does to the signal disposition later. + var noSignal: Int32 = 1 + setsockopt( + clientDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + handleConnection( + UnixSocketConnection( + fileDescriptor: clientDescriptor, + endpoint: .unixSocket(socketURL), + readTimeout: 5, + writeTimeout: 5)) + } } -} -dispatchMain() + withExtendedLifetime((replayCleanupTask, terminateSource, interruptSource)) { + dispatchMain() + } +#endif diff --git a/investigation/.gitignore b/investigation/.gitignore new file mode 100644 index 00000000..8154ccb8 --- /dev/null +++ b/investigation/.gitignore @@ -0,0 +1,14 @@ +spikes/**/.build/ +spikes/**/*.exe +spikes/**/*.lib +spikes/**/*.exp +spikes/**/*.obj +spikes/**/build.log +spikes/**/run.log +spikes/**/test.log +spikes/**/manifest.log +spikes/**/daemon.log +spikes/**/ready.txt +spikes/swift-full/Sources/ +spikes/swift-portable/Sources/ +spikes/swift-contracts/Sources/ diff --git a/investigation/contracts/daemon-protocol-v2.md b/investigation/contracts/daemon-protocol-v2.md new file mode 100644 index 00000000..82ad2703 --- /dev/null +++ b/investigation/contracts/daemon-protocol-v2.md @@ -0,0 +1,97 @@ +# Daemon protocol v2 contract + +## Compatibility + +- Existing protocol-v1 frames remain valid. +- The daemon identifies v2 only when a frame contains the explicit envelope `version` or + `kind` fields. +- A v2 client sends `hello` and negotiates the highest shared version. +- Protocol-v1 clients continue receiving their existing event shapes. +- Protocol-v1 retirement is outside the Windows port. + +## Envelope + +Protocol v2 uses the existing four-byte big-endian frame header and a bounded JSON payload. + +Kinds: + +- `hello`: supported versions +- `request`: request ID and `DaemonCommand` +- `response`: request ID and either a `DaemonEvent` or explicit `success: true` with no + payload +- `event`: sequence and `DaemonEvent` +- `error`: optional request ID plus stable code/message + +The v2 envelope payload is limited to 1 MiB after the envelope is identified. Legacy v1 +frames retain their UInt32 length header and are accepted through the documented 2 MiB +legacy safety ceiling, which bounds allocation while preserving the deployed oversized +fixtures. Transport implementations must handle partial reads/writes, deadlines, +cancellation, backpressure, and non-reading peers. + +## Subscription and reconnect + +- Responses are correlated only by request ID. +- Events carry a monotonically increasing connection-visible sequence. +- `hello` may carry a `clientID`, `resumeFrom` cursor, and an optional project-path + subscription allow-list. An omitted allow-list subscribes to every joined project. +- The daemon keeps a bounded replay window per logical `clientID` (128 events by default), + with bounded client count and expiry, independent of a socket. A reconnect replays events + strictly after `resumeFrom`; unknown or expired history receives `replayUnavailable`, while + a cursor beyond the retained latest sequence receives `cursorOutsideWindow`. +- Subscriptions are tracked per socket for filtering, while canonical retention uses the + union of all sockets for a logical client; one socket cannot narrow another socket's + replay history. +- Canonical subscribed graph events are retained for a logical client while its socket is + disconnected, subject to the same bounded capacity and expiry. +- When retention capacity is full of active clients, an overflow client may receive live + events without a replay buffer; later promotion preserves its sequence and watermark + state rather than resetting or duplicating visible sequences. +- Canonical graph appends retry admission for eligible overflow clients before assigning + their event sequence, so an inactive retained client can be evicted and the active + client promoted on the production broadcast path. +- With `maxClients: 0`, active clients still share monotonic append/snapshot sequences and + watermarks, but retain no replay history; after final disconnect, reconnect starts a new + sequence window and an older cursor is outside that window. +- Replay frames are queued before live events, preserving sequence order across reconnect. +- Every newly connected app socket completes the restore/global join pair exactly once before + an ordinary project-scoped command; concurrent reader and sender paths share that join. +- The live-event queue used while replay is in progress is bounded by both event count and + encoded bytes. A slow connection that exceeds either bound is failed and closed rather + than allowed to grow daemon memory without limit. +- Multiple sockets sharing one logical client receive the same broadcast envelope and + sequence; project membership is reference-counted by socket, so one socket leaving does + not detach a project still joined by another. +- A connection-local join snapshot consumes a visible sequence but records a replay + non-replayable gap, so another socket's snapshot cannot invalidate the first socket's + resume cursor; disconnecting immediately after the snapshot and resuming from that cursor + is an exact caught-up replay rather than `replayUnavailable`. +- Non-replayable snapshot metadata is compacted into bounded ranges within the replay + window, and a disconnected socket's subscription record is removed while logical + client history remains eligible for retention. +- Complete framed writes are serialized at the transport boundary, including concurrent app + sends. +- Unix transport close waits behind the frame-write queue and rejects later writes, so a + descriptor cannot be closed and reused while an earlier header/payload pair is active. +- Replay stores run periodic expiry cleanup while the daemon is idle; expiry does not + require a subsequent append or reconnect attempt. +- Responses and errors are not replayed. They are correlated to the request that produced + them, while subscription events remain sequenced. +- A rejected graph command returns its correlated error and never a successful response + snapshot. +- Before a v2 graph mutation is applied, the daemon preflights both its correlated response + and sequenced event envelope against the 1 MiB cap; an oversized result is rejected + without graph or persistence mutation. Legacy v1 commands retain their larger frame + compatibility. +- After the handshake, an idle socket has no global inactivity timeout. Once the first byte + of a frame arrives, the remaining header and payload share one cumulative read deadline. +- A successful mutation with no response payload returns a correlated response envelope + with `success: true`. +- A reconnect never silently treats an unrelated event as command acknowledgement. + +## Test fixtures + +- Frozen current Swift CLI command. +- Frozen current macOS app command/event exchange. +- Frozen delivered Python remote-shim command. +- Interleaved v2 requests and events. +- Unsupported version, malformed envelope, partial/oversized frame, timeout, and reconnect. diff --git a/investigation/contracts/remote-bridge.md b/investigation/contracts/remote-bridge.md new file mode 100644 index 00000000..3afc0ebb --- /dev/null +++ b/investigation/contracts/remote-bridge.md @@ -0,0 +1,83 @@ +# Windows remote bridge contract + +The first Windows release supports existing POSIX remote hosts. + +## Topology + +```text +remote one-shot Python shim + -> authenticated remote-loopback TCP endpoint + -> SSH reverse forwarding + -> authenticated local 127.0.0.1 bridge + -> Windows graphcoded Named Pipe +``` + +macOS retains its current Unix-socket forwarding path. + +## Bridge state + +An atomically replaced user-only record contains: + +- schema version +- local daemon instance ID +- forward generation +- remote loopback port +- capability +- issued/expiry data +- protocol version + +The record uses `schema_version: 1` and `protocol_version: 1`. `host` is always the +literal `127.0.0.1`; a client must reject another host. `capability` is at least +32 random bytes encoded as lowercase hexadecimal. `generation` is monotonically +increasing for a daemon instance. A rotation may include one `previous` generation +with an explicit finite expiry, bounded by the configured overlap maximum. `issued_at` +and `expires_at` must be finite numeric values, and a configured TTL must be finite and +positive. State replacement and stop cleanup use the same protocol lock; cleanup +compare-and-deletes only when daemon instance, generation, and capability still match. +Generation allocation and publication are also serialized with compare-and-retry; an +active record prevents a second start from orphaning a surviving bridge. Readers must +re-read the record for every one-shot command. Rotation must re-check daemon instance, +generation, and capability under the same lock, reject stale/expired ownership, and +start the overlap clock only after that lock is held. +The complete start/stop/rotation lifecycle must be serialized, including listener +publication, state publication, worker publication, and cleanup. A daemon instance ID +is allocated once per bridge lifetime and remains stable across rotations. The bridge +must bound active client connections, enforce a cumulative frame-read deadline, track +active sockets, and close/join them during stop. + +The one-shot shim reads the record on every command, so already-running zmx sessions +discover replacements after daemon restart, SSH reconnect, remote reboot, or shim upgrade. +Rotation permits a bounded prior generation to avoid update races. + +## Security + +- random capability of at least 256 bits +- strict host-key verification +- `ExitOnForwardFailure` +- explicit remote bind to `127.0.0.1` +- verification of the effective listener despite server `GatewayPorts` +- collision retry, expiry, stale cleanup, and sanitized diagnostics +- exact lowercase ASCII capability validation before constant-time comparison +- finite, nonnegative overlap configuration before clamping +- no network-exposed or unauthenticated daemon transport + +The isolated `investigation/spikes/remote-bridge` proof demonstrates these state, +framing, authentication, rotation, expiry, collision, restart, and loopback +properties with a POSIX-compatible Python shim and a Named Pipe-like framed backend. +It is not a production implementation. + +## Gates + +- invalid, expired, previous-generation, and missing capability tests +- daemon/SSH/remote restart rediscovery +- multiple hosts and projects +- network loss and stale state +- real remote create/send/memo/status through the tunnel + +## Required production changes + +Before integration, production must add a current-user Windows ACL for both the state +record and Named Pipe, overlapped Named Pipe I/O with cancellation/deadlines, and +strict SSH reverse-forward checks (`StrictHostKeyChecking`, `ExitOnForwardFailure`, +explicit remote `127.0.0.1`, and effective-listener verification). Production must +also define ownership-safe stale cleanup and protected-host integration fixtures. diff --git a/investigation/contracts/winghostty-host.md b/investigation/contracts/winghostty-host.md new file mode 100644 index 00000000..249f1a00 --- /dev/null +++ b/investigation/contracts/winghostty-host.md @@ -0,0 +1,36 @@ +# Winghostty embeddable host contract + +Provider: `coneilen/winghostty` + +## Ownership + +- GraphCode owns the top-level HWND and sole Win32 message loop. +- The host owns registered terminal child-window procedures. +- Each surface owns its HWND, HDC, HGLRC, Ghostty core surface, renderer resources, attach + client process, and callbacks. +- Every call and callback declares UI-thread affinity. +- Surface creation copies command, cwd, environment, and callback configuration. +- Destruction is synchronous or awaitable and guarantees no later callback, posted child + work, renderer access, or process access. + +## Required API + +- initialize/deinitialize host +- create/destroy surface under a caller parent HWND +- set bounds, visibility, focus, theme, and font scale +- optional non-blocking UI-thread drain hook +- callbacks for exit, title/cwd, bell, notification, redraw, focus, and fatal error + +## Excluded provider policy + +- GraphCode tabs/splits and graph UI +- Winghostty product tabs/settings/updater/recovery/application IPC +- GraphCode daemon protocol or domain types + +## Gates + +- Original Winghostty application remains green. +- External one-surface host. +- External two-surface host. +- Repeated create/destroy without leaked HWND/HDC/HGLRC/process/thread resources. +- Independent focus/input/IME/clipboard/DPI/UIA. diff --git a/investigation/contracts/zmx-platform.md b/investigation/contracts/zmx-platform.md new file mode 100644 index 00000000..07838f69 --- /dev/null +++ b/investigation/contracts/zmx-platform.md @@ -0,0 +1,37 @@ +# zmx Windows platform contract + +Provider: `coneilen/zmx` + +## Preserve + +- real zmx CLI and wire tags/header/info layout +- long-lived bidirectional attach +- terminal state and scrollback through libghostty-vt +- `run`, `attach`, `send`, `get`, `set`, `kill`, resize, labels, errors +- GraphCode non-leader mouse-input behavior + +## Platform interfaces + +- PTY/process lifecycle +- client/server local IPC +- event wait and cancellation +- resize/control events +- runtime paths and security +- daemon lifetime +- task-shell behavior + +Windows implementations use ConPTY, Named Pipes, Job Objects, and explicit Windows process +creation. A custom line protocol or capped raw-output snapshot does not satisfy the contract. + +## Multi-attach + +Choose one explicit same-session policy: reject, shared attach, or leadership transfer. +Test input routing, resize ownership, transfer/rejection, detach/reconnect, and session +health with concurrent clients. + +## Gates + +- real CLI black-box tests +- VT reconstruction after detach/reconnect +- Unicode, bracketed paste, control events, stale cleanup, client/daemon crashes +- at least one real coding-agent TUI diff --git a/investigation/decisions.md b/investigation/decisions.md new file mode 100644 index 00000000..ee43846c --- /dev/null +++ b/investigation/decisions.md @@ -0,0 +1,110 @@ +# Architecture decisions + +## ADR-001: Keep orchestration and domain logic in Swift + +### Status +Accepted for the port investigation. + +### Evidence +Swift 6.3.3 built `IdentifiedCollections` and a 31-file GraphCode domain target on Windows. +JSON/settings tests passed. Source audit classifies 39 of 62 GraphcodeKit files as portable +unchanged and 17 as shared after abstraction. + +### Decision +Keep GraphcodeKit and graphcoded orchestration in Swift. Add explicit platform services; +do not duplicate graph/session/backend policy in Zig or C++. + +### Consequences +The Windows package must redistribute the Swift runtime. SwiftPM becomes a supported +shared-core build path alongside Tuist. + +## ADR-002: Use a process boundary for the Windows shell + +### Status +Accepted. + +### Decision +The native Windows shell talks to Swift `graphcoded` through the daemon protocol. Do not +start with a Swift DLL/C ABI embedded in Zig. + +### Evidence +The daemon already owns state and survives UI restarts. A Swift Named Pipe spike supports +the required local communication patterns. + +### Consequences +UI crashes do not take orchestration down. Protocol correlation/versioning should be +hardened before multiple rich clients are shipped. + +## ADR-003: Use Named Pipes for Windows daemon IPC + +### Status +Accepted, pending security hardening. + +### Evidence +The Swift/WinSDK spike passed request/response, events, simultaneous clients, reconnect, +unavailable-daemon, connection-availability timeout, and oversized-frame rejection. + +### Decision +Retain JSON and four-byte length framing over a Windows Named Pipe transport. + +### Consequences +Replace raw descriptors in `GraphStore` and `ProjectRegistry` with a connection abstraction. +Use overlapped I/O, connected-operation deadlines/cancellation, bounded frames, and an +explicit current-user ACL in production. Add request correlation/versioning before rich +multi-client use. + +## ADR-004: Port zmx cross-platform rather than create zmx-win + +### Status +Conditional; requires a source-integrated protocol prototype. + +### Evidence +A Windows primitive spike combined ConPTY, Named Pipes, Job Objects, short client +connections, background output, a raw-buffer snapshot, and cleanup. It did not implement +zmx's actual protocol or long-lived attach behavior. + +### Decision +First rebase GraphCode's mouse patch and build a prototype inside current zmx preserving +its wire ABI/CLI, long-lived attach, VT reconstruction, resize, and attach leadership. +Then add platform modules. Create a separate fork only if upstream rejects the required +boundaries or semantics. + +### Consequences +The work is a real backend port, not a small compile fix, and approval remains conditional. +Task mode, signals, shell semantics, and event-loop plumbing need separate Windows +implementations. + +## ADR-005: Do not declare Ghostty surface embedding solved by a VT-only spike + +### Status +Accepted. + +### Context +`libghostty-vt` can drive terminal state and public row/cell APIs in a GraphCode-owned +Win32 window. That is useful but is not the complete Ghostty renderer/application runtime. +Upstream's full embedder API is macOS/iOS-only and exposes no HWND surface API. + +### Decision +Treat a full two-surface Ghostty renderer/input/IME/clipboard/accessibility spike as the +UI go/no-go gate. Do not commit the product to a Winghostty fork or to a custom GDI +terminal renderer based only on VT success. + +### Consequences +Headless Windows work can proceed while the UI architecture remains provisional. + +## ADR-006: Include POSIX remote SSH parity in Windows v1 + +### Status +Supersedes the earlier deferral decision. + +### Evidence +Remote support embeds POSIX shell, Unix-domain sockets, chmod/shebang behavior, `/usr/bin/ssh`, +and reverse Unix socket forwarding throughout several files. + +### Decision +Windows v1 includes the existing POSIX remote-host workflow. Keep Named Pipes as the local +daemon transport and bridge them through an authenticated, loopback-only TCP listener used +only by SSH reverse forwarding. The one-shot remote shim discovers the current endpoint and +capability through an atomic user-only bridge-state record. + +Windows remote hosts, ARM64, and automatic updating remain deferred. diff --git a/investigation/ghostty-windows-embedding.md b/investigation/ghostty-windows-embedding.md new file mode 100644 index 00000000..0087f8f1 --- /dev/null +++ b/investigation/ghostty-windows-embedding.md @@ -0,0 +1,76 @@ +# Ghostty Windows embedding assessment + +Revisions examined: + +- Ghostty `fad7f854e8f976968bf4d61d408de9699cf87666` +- Winghostty `dccedf73600e0ef59c938aa8997f378f27d08f31` + +## Public API boundary + +`libghostty-vt` is usable on Windows and exposes terminal state, VT writes, resize, +callbacks, render snapshots, row/cell iterators, styles/colors/graphemes, key/mouse/focus +encoders, selection, snapshots, Kitty graphics support, and paste validation. + +It intentionally does not provide HWND creation, ConPTY/process launch, Win32 events, +clipboard ownership, font shaping/rasterization, glyph atlases, GPU contexts, compositor, +or presentation. + +Upstream `ghostty.h` is documented as an internal macOS embedder API. Its platform payloads +are NSView/UIView, and upstream has no Win32 application runtime or HWND surface API. + +## Spike + +`investigation/spikes/ghostty-custom-window` proves: + +- one GraphCode-owned top-level HWND +- two child terminal HWNDs +- independent `GhosttyTerminal` and `GhosttyRenderState` values +- public row/cell iteration +- independent GDI painting + +Smoke result: + +```text +SMOKE PASS: created=2 painted A=1 B=1 independent-terminal-state=PASS +``` + +This is a VT/state and window-topology proof, not a production Ghostty renderer proof. + +## Winghostty dependency map + +Winghostty's internal runtime demonstrates the desired topology: + +- `src/apprt/win32.zig`: `App`, `Host`, `Surface`, HWND lifecycle, input, DPI, focus, + clipboard, tabs/splits, accessibility, repaint scheduling +- `src/Surface.zig`, `src/App.zig`: terminal/application core +- `src/renderer.zig`, `src/renderer/OpenGL.zig`, `src/renderer/opengl/*`: WGL/OpenGL + terminal rendering +- `src/pty.zig`: Windows pipes and ConPTY +- `src/Command.zig`: `CreateProcessW`, pseudoconsole attribute, process lifetime + +Each Winghostty surface owns an HWND, HDC, HGLRC, core surface, and host association. +Rendering uses `wglMakeCurrent` and `SwapBuffers`. Multiple complete surfaces are therefore +technically possible. + +There is no exported `create_surface(parent_hwnd)` boundary. Extracting it crosses a wide +import graph including compositor, shell, clipboard, UIA, settings, tabs, recovery, +drag/drop, IPC, and theme code. + +## Build evidence + +- Current Ghostty `zig build -Demit-lib-vt=true` succeeds with Zig 0.16.0. +- Shared/static C spike builds and runs. +- Winghostty requires Zig 0.15.2 but its build runner failed on an absolute child cwd. +- Zig 0.16.0 is incompatible with that checkout's declared version and build APIs. +- `libghostty-vt` tests hung for more than five minutes and were stopped; result is + inconclusive. + +## Decision + +- **Go** for `libghostty-vt` with a GraphCode-owned renderer. +- **No-go today** for embedding a complete upstream Ghostty surface via public APIs. +- **Conditional go** for extracting/maintaining Winghostty's internal Win32/OpenGL runtime. + +The production UI cannot be estimated as a thin wrapper. It requires either a substantial +Winghostty runtime extraction/fork or a production terminal renderer/input stack owned by +GraphCode. diff --git a/investigation/licensing.md b/investigation/licensing.md new file mode 100644 index 00000000..ec24ff3a --- /dev/null +++ b/investigation/licensing.md @@ -0,0 +1,17 @@ +# Licensing notes + +| Component | License | Windows-port consequence | +|---|---|---| +| `GraphcodeKit/` | MIT | May be reused, modified, and redistributed with notice. | +| `graphcode-cli/` | MIT | Same. | +| `graphcoded/`, app, remaining repository | FSL-1.1-MIT | Internal, non-commercial research, education, and permitted professional services are allowed. A competing commercial product/service is restricted until each version's two-year MIT future-license date. Preserve the license on redistribution. | +| Ghostty / `libghostty-vt` | MIT | Reuse is permitted with copyright/license notice. | +| zmx and GraphCode's zmx fork | MIT | Reuse/port is permitted with upstream notice. Record fork SHA and upstream base. | +| Winghostty | Ghostty-derived MIT code; verify per-file headers | Concepts and code may be adapted with notices, but copied files need provenance and header review. | + +GraphCode's current zmx fork is 26 upstream commits behind and carries a GraphCode-specific +mouse-input patch. Windows work should rebase that patch before building a new backend. + +This is an engineering inventory, not legal advice. Before public Windows distribution, +generate a third-party notice/SBOM from the exact pinned commits and review every copied +Winghostty file rather than relying only on repository-level READMEs. diff --git a/investigation/open-questions.md b/investigation/open-questions.md new file mode 100644 index 00000000..543a5807 --- /dev/null +++ b/investigation/open-questions.md @@ -0,0 +1,36 @@ +# Open questions and go/no-go gates + +## Must answer before production UI work + +1. Can a GraphCode-owned Win32 window host the complete Ghostty renderer/input stack, not only `libghostty-vt` state rendered by a custom GDI client? +2. Can two complete surfaces share a compositor without focus, DPI, IME, accessibility, or teardown leaks? +3. Which Winghostty modules can be extracted on a maintained Zig/Ghostty revision? +4. What upstream relationship will prevent GraphCode from carrying a permanent Ghostty application-runtime fork? + +## Must answer before daemon release + +1. Exact current-user Named Pipe ACL and SID-derived naming. +2. Overlapped-I/O deadlines/cancellation for connect, header read, body read, and writes. +3. Maximum frame size, backpressure, partial-frame, and non-reading-peer behavior. +4. Protocol request correlation/versioning; the current “next matching broadcast” behavior is fragile with multiple active clients. +5. Event subscription, ordering, and reconnect/replay semantics for multiple clients. +6. Windows startup choice: Startup shortcut, scheduled task, packaged app startup task, or explicit app-managed child. +7. Swift runtime redistribution and installer footprint. +8. Authenticated bridge-state schema, capability rotation, stale cleanup, and endpoint + rediscovery after daemon/SSH/remote restart. +9. Verification that the SSH server's effective reverse-forward listener remains + loopback-only regardless of `GatewayPorts`. + +## Must answer before zmx release + +1. Rebase/upstream the GraphCode mouse-input patch. +2. Define multiple attach leadership behavior on Windows. +3. Verify VT snapshot restoration against real ConPTY streams and coding-agent TUIs. +4. Test resize, Ctrl+C/Ctrl+Break, Unicode, bracketed paste, stale-session cleanup, daemon/client crashes, and reboot recovery. +5. Decide whether task-mode POSIX shell features are supported natively, through PowerShell, or deferred. + +## Deferred from Windows v1 + +- WSL-specific project/path integration. +- ARM64. +- Full updater/installer automation. diff --git a/investigation/spikes/README.md b/investigation/spikes/README.md new file mode 100644 index 00000000..7ee6e20d --- /dev/null +++ b/investigation/spikes/README.md @@ -0,0 +1,28 @@ +# Windows feasibility spikes + +All Swift spikes were run with Swift 6.3.3 on Windows 11. + +The official toolkit used in this investigation installed under: + +```text +%LOCALAPPDATA%\Programs\Swift\ +``` + +Before running SwiftPM, put the selected toolchain and runtime `usr\bin` directories on +`PATH` and set `SDKROOT` to the matching Windows SDK inside the Swift installation. + +| Directory | Purpose | Last result | +|---|---|---| +| `swift-full` | Compile all GraphcodeKit sources through SwiftPM | Dependencies build; fails at `PTYProcessSession.swift: import Darwin` | +| `swift-portable` | Compile/test 31 portable domain files | Pass, 2 tests | +| `swift-paths` | Execute current path algorithms on Windows paths | Pass; reproduces 3 blockers | +| `swift-named-pipe` | Swift/WinSDK daemon transport behaviors | Pass, 7 behaviors; connected-I/O deadlines remain untested | +| `swift-process` | Foundation `Process`, argv, cwd, environment, scripts | Pass | +| `zmx-conpty` | ConPTY + Named Pipe + Job Object primitive survival/snapshot | Pass; not actual zmx protocol parity | +| `ghostty-custom-window` | GraphCode-owned HWND with two libghostty-vt-backed views | Pass for VT/state rendering; not a full Ghostty renderer proof | + +The `swift-full` and `swift-portable` setup scripts create directory junctions into the +repository so source is not duplicated. + +Generated `.build` directories, executables, libraries, and runtime logs are not required +source artifacts and should not be committed. diff --git a/investigation/spikes/ghostty-custom-window/README.md b/investigation/spikes/ghostty-custom-window/README.md new file mode 100644 index 00000000..b3181f60 --- /dev/null +++ b/investigation/spikes/ghostty-custom-window/README.md @@ -0,0 +1,44 @@ +# Ghostty/libghostty-vt Win32 embedding spike + +This is a minimal, external-host proof for the current public Ghostty VT API. It does **not** embed Ghostty's complete GUI/runtime surface. + +## What it proves + +- Creates a GraphCode-like Win32 top-level host window. +- Creates two `WS_CHILD` terminal windows owned by that host. +- Keeps two independent `GhosttyTerminal` and `GhosttyRenderState` instances. +- Feeds independent VT text to each terminal. +- Uses the public render-state row/cell iterators to paint styled ASCII cells with GDI. +- Runs a smoke check requiring both child HWNDs to be created and painted. + +## Source and upstream inputs + +- Source: `spike.c` +- Upstream Ghostty revision used: `fad7f854e8f976968bf4d61d408de9699cf87666` +- The upstream build was `zig build -Demit-lib-vt=true` with Zig 0.16.0. + +## Re-run without storing bulky outputs here + +Set `$ghosttyBuild` to a local Ghostty build produced with +`zig build -Demit-lib-vt=true`. Build from this directory and write generated outputs to a +temporary directory: + +```powershell +$src = Join-Path (Get-Location) 'spike.c' +$out = Join-Path $env:TEMP 'graphcode-ghostty-window-spike' +$inc = Join-Path $ghosttyBuild 'include' +$lib = Join-Path $ghosttyBuild 'lib\ghostty-vt.lib' +New-Item -ItemType Directory -Force $out | Out-Null +clang-cl /nologo /W4 /I $inc /c $src /Fo:(Join-Path $out 'spike-from-graphcode.obj') +clang-cl /nologo (Join-Path $out 'spike-from-graphcode.obj') $lib user32.lib gdi32.lib advapi32.lib shell32.lib /Fe:(Join-Path $out 'spike-from-graphcode.exe') +Copy-Item (Join-Path $ghosttyBuild 'bin\ghostty-vt.dll') $out -Force +& (Join-Path $out 'spike-from-graphcode.exe') +``` + +Expected result includes: + +```text +SMOKE PASS: created=2 painted A=1 B=1 independent-terminal-state=PASS +``` + +Build outputs are intentionally excluded from this GraphCode investigation directory. diff --git a/investigation/spikes/ghostty-custom-window/spike.c b/investigation/spikes/ghostty-custom-window/spike.c new file mode 100644 index 00000000..83287780 --- /dev/null +++ b/investigation/spikes/ghostty-custom-window/spike.c @@ -0,0 +1,224 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include + +typedef struct Surface { + HWND hwnd; + GhosttyTerminal terminal; + GhosttyRenderState render; + GhosttyRenderStateRowIterator rows; + GhosttyRenderStateRowCells cells; + int paint_count; + const char* title; +} Surface; + +static Surface g_surfaces[2]; +static HWND g_host; +static const wchar_t* HOST_CLASS = L"GraphCodeEmbeddingSpikeHost"; +static const wchar_t* SURFACE_CLASS = L"GraphCodeEmbeddingSpikeSurface"; + +static COLORREF to_color(GhosttyColorRgb c) { + return RGB(c.r, c.g, c.b); +} + +static GhosttyColorRgb style_color(GhosttyStyleColor color, + const GhosttyRenderStateColors* colors, + GhosttyColorRgb fallback) { + if (color.tag == GHOSTTY_STYLE_COLOR_RGB) return color.value.rgb; + if (color.tag == GHOSTTY_STYLE_COLOR_PALETTE) return colors->palette[color.value.palette]; + return fallback; +} + +static void paint_surface(Surface* surface, HDC dc) { + GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + if (ghostty_render_state_colors_get(surface->render, &colors) != GHOSTTY_SUCCESS) return; + + uint16_t cols = 0, rows = 0; + if (ghostty_render_state_get(surface->render, GHOSTTY_RENDER_STATE_DATA_COLS, &cols) != GHOSTTY_SUCCESS || + ghostty_render_state_get(surface->render, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows) != GHOSTTY_SUCCESS) return; + + HFONT font = CreateFontW(16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, FIXED_PITCH | FF_MODERN, L"Consolas"); + HGDIOBJ old_font = SelectObject(dc, font); + SetBkMode(dc, OPAQUE); + + if (ghostty_render_state_get(surface->render, + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, + &surface->rows) != GHOSTTY_SUCCESS) return; + + int cell_w = 10, cell_h = 20; + int row_index = 0; + while (row_index < rows && ghostty_render_state_row_iterator_next(surface->rows)) { + if (ghostty_render_state_row_get(surface->rows, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &surface->cells) != GHOSTTY_SUCCESS) break; + int col_index = 0; + while (col_index < cols && ghostty_render_state_row_cells_next(surface->cells)) { + GhosttyStyle style = GHOSTTY_INIT_SIZED(GhosttyStyle); + if (ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, &style) != GHOSTTY_SUCCESS) break; + + GhosttyColorRgb fg = style_color(style.fg_color, &colors, colors.foreground); + GhosttyColorRgb bg = style_color(style.bg_color, &colors, colors.background); + SetTextColor(dc, to_color(fg)); + SetBkColor(dc, to_color(bg)); + + uint32_t grapheme_len = 0; + ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, &grapheme_len); + char ch = ' '; + if (grapheme_len > 0) { + uint32_t codepoints[16] = {0}; + ghostty_render_state_row_cells_get(surface->cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, codepoints); + if (codepoints[0] >= 32 && codepoints[0] < 127) ch = (char)codepoints[0]; + } + RECT cell = { col_index * cell_w, row_index * cell_h, + (col_index + 1) * cell_w, (row_index + 1) * cell_h }; + ExtTextOutA(dc, cell.left + 1, cell.top + 1, ETO_OPAQUE, &cell, &ch, 1, NULL); + col_index++; + } + row_index++; + } + + SelectObject(dc, old_font); + DeleteObject(font); + surface->paint_count++; +} + +static LRESULT CALLBACK host_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_ERASEBKGND: + return 1; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + RECT r; + GetClientRect(hwnd, &r); + HBRUSH brush = CreateSolidBrush(RGB(30, 30, 35)); + FillRect(dc, &r, brush); + DeleteObject(brush); + EndPaint(hwnd, &ps); + return 0; + } + case WM_DESTROY: + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static LRESULT CALLBACK surface_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + Surface* surface = (Surface*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (msg == WM_NCCREATE) { + CREATESTRUCTW* create = (CREATESTRUCTW*)lp; + surface = (Surface*)create->lpCreateParams; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)surface); + surface->hwnd = hwnd; + } + switch (msg) { + case WM_ERASEBKGND: + return 1; + case WM_PAINT: { + PAINTSTRUCT ps; + HDC dc = BeginPaint(hwnd, &ps); + if (surface) paint_surface(surface, dc); + EndPaint(hwnd, &ps); + return 0; + } + } + return DefWindowProcW(hwnd, msg, wp, lp); +} + +static int fail(const char* message) { + fprintf(stderr, "FAIL: %s (win32=%lu)\n", message, (unsigned long)GetLastError()); + return 1; +} + +static int init_surface(Surface* surface, const char* title, const char* content) { + GhosttyResult result = ghostty_terminal_new(NULL, &surface->terminal, 40, 8); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_new(NULL, &surface->render); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_row_iterator_new(NULL, &surface->rows); + if (result != GHOSTTY_SUCCESS) return 0; + result = ghostty_render_state_row_cells_new(NULL, &surface->cells); + if (result != GHOSTTY_SUCCESS) return 0; + ghostty_terminal_vt_write(surface->terminal, (const uint8_t*)content, strlen(content)); + result = ghostty_render_state_update(surface->render, surface->terminal); + if (result != GHOSTTY_SUCCESS) return 0; + surface->title = title; + return 1; +} + +static void free_surface(Surface* surface) { + if (surface->cells) ghostty_render_state_row_cells_free(surface->cells); + if (surface->rows) ghostty_render_state_row_iterator_free(surface->rows); + if (surface->render) ghostty_render_state_free(surface->render); + if (surface->terminal) ghostty_terminal_free(surface->terminal); + memset(surface, 0, sizeof(*surface)); +} + +int main(void) { + setvbuf(stdout, NULL, _IONBF, 0); + fprintf(stderr, "stage: start\n"); fflush(stderr); + HINSTANCE instance = GetModuleHandleW(NULL); + WNDCLASSW host_class = {0}; + host_class.hInstance = instance; + host_class.lpfnWndProc = host_proc; + host_class.lpszClassName = HOST_CLASS; + host_class.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32512)); + if (!RegisterClassW(&host_class) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return fail("register host class"); + + WNDCLASSW surface_class = {0}; + surface_class.hInstance = instance; + surface_class.lpfnWndProc = surface_proc; + surface_class.lpszClassName = SURFACE_CLASS; + surface_class.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32513)); + if (!RegisterClassW(&surface_class) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) return fail("register surface class"); + + fprintf(stderr, "stage: before surface A\n"); fflush(stderr); + if (!init_surface(&g_surfaces[0], "surface-a", "Surface A: \033[1;32mindependent\033[0m VT state\r\n")) return fail("initialize surface A"); + fprintf(stderr, "stage: after surface A\n"); fflush(stderr); + if (!init_surface(&g_surfaces[1], "surface-b", "Surface B: \033[1;34mindependent\033[0m VT state\r\n")) return fail("initialize surface B"); + + fprintf(stderr, "stage: after surface B\n"); fflush(stderr); + g_host = CreateWindowExW(0, HOST_CLASS, L"GraphCode-owned embedding host", + WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, + 900, 260, NULL, NULL, instance, NULL); + if (!g_host) return fail("create top-level host HWND"); + + g_surfaces[0].hwnd = CreateWindowExW(WS_EX_CLIENTEDGE, SURFACE_CLASS, L"A", + WS_CHILD | WS_VISIBLE, 12, 12, 420, 180, g_host, NULL, instance, &g_surfaces[0]); + g_surfaces[1].hwnd = CreateWindowExW(WS_EX_CLIENTEDGE, SURFACE_CLASS, L"B", + WS_CHILD | WS_VISIBLE, 444, 12, 420, 180, g_host, NULL, instance, &g_surfaces[1]); + if (!g_surfaces[0].hwnd || !g_surfaces[1].hwnd) return fail("create two child terminal HWNDs"); + + fprintf(stderr, "stage: after windows\n"); fflush(stderr); + printf("HOST hwnd=%p CHILD_A hwnd=%p CHILD_B hwnd=%p\n", + (void*)g_host, (void*)g_surfaces[0].hwnd, (void*)g_surfaces[1].hwnd); + ShowWindow(g_host, SW_SHOWNOACTIVATE); + UpdateWindow(g_host); + UpdateWindow(g_surfaces[0].hwnd); + UpdateWindow(g_surfaces[1].hwnd); + + if (g_surfaces[0].paint_count < 1 || g_surfaces[1].paint_count < 1) { + fprintf(stderr, "FAIL: child paint smoke test A=%d B=%d\n", + g_surfaces[0].paint_count, g_surfaces[1].paint_count); + DestroyWindow(g_host); + free_surface(&g_surfaces[0]); + free_surface(&g_surfaces[1]); + return 1; + } + + printf("SMOKE PASS: created=2 painted A=%d B=%d independent-terminal-state=PASS\n", + g_surfaces[0].paint_count, g_surfaces[1].paint_count); + + DestroyWindow(g_host); + free_surface(&g_surfaces[0]); + free_surface(&g_surfaces[1]); + return 0; +} diff --git a/investigation/spikes/remote-bridge/.gitignore b/investigation/spikes/remote-bridge/.gitignore new file mode 100644 index 00000000..d3b306c6 --- /dev/null +++ b/investigation/spikes/remote-bridge/.gitignore @@ -0,0 +1,3 @@ +.test-state-*/ +__pycache__/ +*.pyc diff --git a/investigation/spikes/remote-bridge/README.md b/investigation/spikes/remote-bridge/README.md new file mode 100644 index 00000000..b1c651e3 --- /dev/null +++ b/investigation/spikes/remote-bridge/README.md @@ -0,0 +1,90 @@ +# Authenticated Windows remote-bridge spike + +This is an isolated contract proof. It does not change `GraphcodeKit` or the existing +remote implementation. The Python fixture models the local Windows bridge and a +Named Pipe-like four-byte framed backend using loopback sockets so the same test runs +on Windows and POSIX. + +## Run + +```text +python -B -m unittest discover -s investigation/spikes/remote-bridge -p test_*.py -v +pwsh Tools/windows/validate.ps1 -Task remote-bridge +``` + +The executable parity tier is separate: + +```text +pwsh Tools/windows/validate.ps1 -Task remote-e2e +``` + +It always runs the deterministic local OpenSSH/WSL fixture and only probes external +POSIX hosts when `GRAPHCODE_REMOTE_E2E_TARGETS` is set. Configured targets are +mandatory and failures fail the run; the variable must not contain empty entries. +The fixture derives its host key before connecting and uses a per-run +`UserKnownHostsFile`; it never reads or modifies the user's default `known_hosts`, so +there are no default-file entries to clean up. + +`remote_client.py` is a one-shot, POSIX-compatible shim. It reads the state record for +each request and sends one framed JSON request: + +```text +python investigation/spikes/remote-bridge/remote_client.py STATE.json "{\"command\":\"status\"}" +``` + +The test fixture starts the backend and bridge itself; no real host, credential, SSH +endpoint, or private network is used. + +## Proofed behavior + +- `schema_version` and `protocol_version` are validated on every state read. +- Capabilities contain 256 random bits and are never included in diagnostics. +- TTL and all current/previous-generation timestamps must be finite; expiry cannot be + disabled with `NaN` or infinity. +- Capabilities must be exactly 64 lowercase ASCII hex characters; malformed input is + rejected before constant-time comparison. +- Overlap configuration must be finite and nonnegative before any maximum clamp. +- State writes use a user-only temporary file, flush and `fsync`, then `os.replace`. +- The listener is explicitly bound to `127.0.0.1`; Windows uses exclusive-address binding + when available. +- A requested-port collision retries with an ephemeral loopback port. +- Rotation issues a new generation and permits at most one bounded previous generation. +- Expired, missing, malformed, oversized, and invalid-token requests are rejected. +- Stop/restart replaces stale state and a one-shot client rediscovers the new record. +- Stop cleanup compare-and-deletes only its matching daemon/generation/capability record. +- Concurrent starts allocate and publish under one state transaction; an active owner + rejects a competing start instead of orphaning a surviving bridge. +- Rotation re-checks ownership under the state lock, rejects stale/expired bridges, and + starts overlap timing only after lock acquisition. +- Start, stop, and rotation serialize the complete bridge lifecycle, including listener, + state, worker publication, and cleanup. +- Each bridge keeps one daemon instance ID across rotations; connections are bounded, + frame reads use a cumulative deadline, and stop closes tracked sockets and joins workers. +- Backend failures return a stable sanitized error. +- Tests keep bridge state in OS temporary storage outside the repository. +- `RemoteBridgePrivacyRace.Tests.ps1` runs remote tests and privacy validation concurrently. + +## TDD evidence + +RED: focused lifecycle tests failed before serialization, stable daemon identity, bounded +workers, cumulative deadlines, and tracked-socket cleanup were implemented. +GREEN: `python -B -m unittest discover -s investigation/spikes/remote-bridge -p test_*.py -v` +plus `pwsh Tools/windows/Tests/RemoteBridgePrivacyRace.Tests.ps1` -> 26 tests and race +regression passed +REGRESSION: `pwsh Tools/windows/validate.ps1 -Task remote-bridge` -> focused Windows validation, privacy race, and rotation ownership/timing checks passed + +## Required production contract changes + +This spike does not claim production readiness. Before integration, the production +contract must additionally specify: + +1. A Windows user-only ACL for the state record and Named Pipe, with an explicit test + that another local user cannot read or connect. +2. Overlapped Named Pipe I/O with cancellation and bounded deadlines, preserving the + existing four-byte frame limit and protocol-v2 envelope. +3. SSH reverse forwarding with strict host-key verification, `ExitOnForwardFailure`, + explicit remote `127.0.0.1` binding, and verification of the effective listener. +4. A production stale-state ownership check and cleanup policy that cannot remove a + newer daemon's record. +5. Protected integration tests for reconnect, remote restart, multiple projects, and + real `create`/`send`/`memo`/`status` exchanges using runner-provided fixtures only. diff --git a/investigation/spikes/remote-bridge/remote_bridge.py b/investigation/spikes/remote-bridge/remote_bridge.py new file mode 100644 index 00000000..ae02fd4c --- /dev/null +++ b/investigation/spikes/remote-bridge/remote_bridge.py @@ -0,0 +1,986 @@ +"""Authenticated loopback TCP to framed backend remote-bridge proof. + +The backend is deliberately socket-based so this fixture runs on POSIX and Windows. +It models the four-byte framed I/O used by the Windows Named Pipe spike without +touching GraphcodeKit or the production remote implementation. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import errno +import hmac +import json +import math +import os +import re +import secrets +import socket +import stat +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + + +MAX_FRAME_BYTES = 1_048_576 +LOOPBACK = "127.0.0.1" +SCHEMA_VERSION = 1 +PROTOCOL_VERSION = 1 +CAPABILITY_BYTES = 32 +DEFAULT_MAX_PREVIOUS_OVERLAP_SECONDS = 5.0 + + +class RemoteBridgeError(Exception): + """Sanitized bridge, state, or framing failure.""" + + +class FrameTooLarge(RemoteBridgeError): + """The peer supplied a frame larger than the protocol limit.""" + + +def _json_bytes(value: Dict[str, Any]) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _recv_exact( + connection: socket.socket, + count: int, + deadline: Optional[float] = None, +) -> bytes: + chunks = [] + remaining = count + while remaining: + if deadline is not None: + timeout = deadline - time.monotonic() + if timeout <= 0: + raise TimeoutError("frame read deadline exceeded") + connection.settimeout(timeout) + chunk = connection.recv(remaining) + if not chunk: + raise RemoteBridgeError("connection closed while reading frame") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def read_frame( + connection: socket.socket, + deadline: Optional[float] = None, +) -> Dict[str, Any]: + header = _recv_exact(connection, 4, deadline) + size = int.from_bytes(header, "big") + if size > MAX_FRAME_BYTES: + raise FrameTooLarge("frame exceeds protocol limit") + try: + value = json.loads( + _recv_exact(connection, size, deadline).decode("utf-8") + ) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RemoteBridgeError("invalid framed JSON") from error + if not isinstance(value, dict): + raise RemoteBridgeError("framed payload must be an object") + return value + + +def send_frame(connection: socket.socket, value: Dict[str, Any]) -> None: + payload = _json_bytes(value) + if len(payload) > MAX_FRAME_BYTES: + raise FrameTooLarge("frame exceeds protocol limit") + connection.sendall(len(payload).to_bytes(4, "big") + payload) + + +def _capability() -> str: + return secrets.token_hex(CAPABILITY_BYTES) + + +def _is_collision(error: OSError) -> bool: + return error.errno == errno.EADDRINUSE or getattr(error, "winerror", None) == 10048 + + +def _is_finite_number(value: Any) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + ) + + +def _is_capability(value: Any) -> bool: + if not isinstance(value, str) or len(value) != 64: + return False + try: + value.encode("ascii") + except UnicodeEncodeError: + return False + return re.fullmatch(r"[0-9a-f]{64}", value) is not None + + +def _validate_previous(previous: Any) -> None: + if previous is None: + return + if not isinstance(previous, dict): + raise RemoteBridgeError("invalid previous generation") + if ( + not isinstance(previous.get("generation"), int) + or isinstance(previous.get("generation"), bool) + or previous["generation"] < 1 + or not _is_capability(previous.get("capability")) + or not _is_finite_number(previous.get("expires_at")) + ): + raise RemoteBridgeError("invalid previous generation") + + +def validate_state(state: Dict[str, Any]) -> Dict[str, Any]: + required = { + "schema_version", + "protocol_version", + "daemon_instance_id", + "generation", + "host", + "port", + "capability", + "issued_at", + "expires_at", + } + if not isinstance(state, dict) or not required.issubset(state): + raise RemoteBridgeError("bridge state is missing required fields") + if state["schema_version"] != SCHEMA_VERSION: + raise RemoteBridgeError("unsupported bridge state schema") + if state["protocol_version"] != PROTOCOL_VERSION: + raise RemoteBridgeError("unsupported bridge protocol") + if ( + not isinstance(state["daemon_instance_id"], str) + or not state["daemon_instance_id"] + or not isinstance(state["generation"], int) + or isinstance(state["generation"], bool) + or state["generation"] < 1 + or state["host"] != LOOPBACK + or not isinstance(state["port"], int) + or isinstance(state["port"], bool) + or not 1 <= state["port"] <= 65535 + or not _is_capability(state["capability"]) + or not _is_finite_number(state["issued_at"]) + or not _is_finite_number(state["expires_at"]) + or state["expires_at"] <= state["issued_at"] + ): + raise RemoteBridgeError("invalid bridge state") + _validate_previous(state.get("previous")) + return state + + +class BridgeStateStore: + """Atomic, user-readable bridge-state record.""" + + _thread_locks_guard = threading.Lock() + _thread_locks = {} + + def __init__(self, path: Path | str): + self.path = Path(path) + key = os.path.abspath(os.fspath(self.path)) + with self._thread_locks_guard: + self._thread_lock = self._thread_locks.setdefault( + key, + threading.RLock(), + ) + self._lock_depth = threading.local() + self._lock_path = self.path.with_name(f".{self.path.name}.lock") + + @contextmanager + def _protocol_lock(self): + depth = getattr(self._lock_depth, "value", 0) + if depth: + self._lock_depth.value = depth + 1 + try: + yield + finally: + self._lock_depth.value = depth + return + + with self._thread_lock: + self._lock_path.parent.mkdir(parents=True, exist_ok=True) + with self._lock_path.open("a+b") as lock_file: + self._acquire_file_lock(lock_file) + self._lock_depth.value = 1 + try: + yield + finally: + self._lock_depth.value = 0 + self._release_file_lock(lock_file) + + @contextmanager + def transaction(self): + with self._protocol_lock(): + yield + + @staticmethod + def _acquire_file_lock(lock_file) -> None: + if os.name == "nt": + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + return + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + + @staticmethod + def _release_file_lock(lock_file) -> None: + if os.name == "nt": + import msvcrt + + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + return + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def write(self, state: Dict[str, Any]) -> None: + validate_state(state) + with self._protocol_lock(): + self._write_unlocked(state) + + def _write_unlocked(self, state: Dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_name( + f".{self.path.name}.{secrets.token_hex(8)}.tmp" + ) + descriptor = os.open( + temporary, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + stat.S_IRUSR | stat.S_IWUSR, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as file: + descriptor = None + json.dump( + state, + file, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + replaced = False + for attempt in range(200): + try: + os.replace(temporary, self.path) + replaced = True + break + except PermissionError: + if attempt == 199: + raise + time.sleep(0.005) + if not replaced: + raise RemoteBridgeError("bridge state replacement failed") + os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR) + finally: + if descriptor is not None: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + + def read(self) -> Dict[str, Any]: + with self._protocol_lock(): + return self._read_unlocked() + + def _read_unlocked(self) -> Dict[str, Any]: + with self._open_for_read() as file: + state = json.load(file) + return validate_state(state) + + def _open_for_read(self): + if os.name != "nt": + return self.path.open("r", encoding="utf-8") + import ctypes + import msvcrt + + create_file = ctypes.windll.kernel32.CreateFileW + create_file.argtypes = [ + ctypes.c_wchar_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ] + create_file.restype = ctypes.c_void_p + handle = create_file( + str(self.path), + 0x80000000, + 0x00000001 | 0x00000002 | 0x00000004, + None, + 3, + 0x00000080, + None, + ) + if handle == ctypes.c_void_p(-1).value: + raise FileNotFoundError(str(self.path)) + descriptor = msvcrt.open_osfhandle(handle, os.O_RDONLY) + return os.fdopen(descriptor, "r", encoding="utf-8") + + def remove(self) -> None: + with self._protocol_lock(): + self._remove_unlocked() + + def _remove_unlocked(self) -> None: + try: + self.path.unlink() + except FileNotFoundError: + pass + + @staticmethod + def _record_matches( + current: Dict[str, Any], + expected: Dict[str, Any], + ) -> bool: + return ( + current["daemon_instance_id"] == expected["daemon_instance_id"] + and current["generation"] == expected["generation"] + and hmac.compare_digest( + current["capability"], + expected["capability"], + ) + ) + + def write_if_matches( + self, + expected: Optional[Dict[str, Any]], + state: Dict[str, Any], + ) -> bool: + validate_state(state) + with self._protocol_lock(): + try: + current = self.read() + except FileNotFoundError: + current = None + if expected is None: + if current is not None: + return False + elif current is None or not self._record_matches(current, expected): + return False + self._write_unlocked(state) + return True + + def remove_if_matches(self, expected: Dict[str, Any]) -> bool: + with self._protocol_lock(): + try: + current = self.read() + except FileNotFoundError: + return False + if not self._record_matches(current, expected): + return False + self.remove() + return True + + +class FramedBackend: + """A Named Pipe-like framed backend fixture for cross-platform tests.""" + + def __init__(self): + self._listener: Optional[socket.socket] = None + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._connections = [] + self._connection_locks = {} + self._lock = threading.Lock() + + @property + def address(self) -> Tuple[str, int]: + if self._listener is None: + raise RemoteBridgeError("backend is not running") + return self._listener.getsockname() + + def start(self) -> None: + if self._listener is not None: + raise RemoteBridgeError("backend is already running") + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((LOOPBACK, 0)) + listener.listen(16) + listener.settimeout(0.1) + self._listener = listener + self._stop.clear() + self._thread = threading.Thread( + target=self._serve, + args=(listener,), + name="remote-bridge-backend", + daemon=True, + ) + self._thread.start() + + def _serve(self, listener: socket.socket) -> None: + while not self._stop.is_set(): + try: + connection, _ = listener.accept() + except socket.timeout: + continue + except OSError: + break + with self._lock: + self._connections.append(connection) + self._connection_locks[connection] = threading.Lock() + threading.Thread( + target=self._handle, + args=(connection,), + name="remote-bridge-backend-client", + daemon=True, + ).start() + + def _handle(self, connection: socket.socket) -> None: + with self._lock: + send_lock = self._connection_locks.get(connection, threading.Lock()) + try: + while True: + request = read_frame(connection) + with send_lock: + send_frame( + connection, + { + "ok": True, + "backend": "named-pipe-like", + "echo": request.get("request"), + }, + ) + except (OSError, RemoteBridgeError): + pass + finally: + with self._lock: + if connection in self._connections: + self._connections.remove(connection) + self._connection_locks.pop(connection, None) + connection.close() + + def broadcast(self, value: Dict[str, Any]) -> None: + """Send a backend event to every currently connected session.""" + with self._lock: + connections = [ + (connection, self._connection_locks[connection]) + for connection in self._connections + if connection in self._connection_locks + ] + for connection, send_lock in connections: + try: + with send_lock: + send_frame(connection, value) + except (OSError, RemoteBridgeError): + pass + + def stop(self) -> None: + self._stop.set() + if self._listener is not None: + self._listener.close() + self._listener = None + with self._lock: + connections = list(self._connections) + self._connections.clear() + self._connection_locks.clear() + for connection in connections: + connection.close() + if self._thread is not None: + self._thread.join(timeout=1) + self._thread = None + + +class RemoteBridge: + """Authenticate loopback clients, then relay their framed session.""" + + def __init__( + self, + state_path: Path | str, + backend_address: Tuple[str, int], + *, + port: int = 0, + ttl_seconds: float = 30.0, + previous_overlap_seconds: float = 1.0, + max_previous_overlap_seconds: float = DEFAULT_MAX_PREVIOUS_OVERLAP_SECONDS, + collision_retries: int = 3, + request_timeout: float = 2.0, + max_connections: int = 16, + ): + if not _is_finite_number(ttl_seconds) or ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + if ( + not _is_finite_number(previous_overlap_seconds) + or previous_overlap_seconds < 0 + ): + raise ValueError("previous_overlap_seconds must be finite and nonnegative") + if ( + not _is_finite_number(max_previous_overlap_seconds) + or max_previous_overlap_seconds < 0 + ): + raise ValueError( + "max_previous_overlap_seconds must be finite and nonnegative" + ) + if not 0 <= port <= 65535: + raise ValueError("port must be between 0 and 65535") + if collision_retries < 0: + raise ValueError("collision_retries must not be negative") + if ( + not isinstance(max_connections, int) + or isinstance(max_connections, bool) + or max_connections < 1 + ): + raise ValueError("max_connections must be positive") + if not _is_finite_number(request_timeout) or request_timeout <= 0: + raise ValueError("request_timeout must be positive") + if backend_address[0] != LOOPBACK: + raise ValueError("backend must use the loopback address") + self.state_store = BridgeStateStore(state_path) + self.backend_address = backend_address + self.requested_port = port + self.ttl_seconds = ttl_seconds + self.previous_overlap_seconds = previous_overlap_seconds + self.max_previous_overlap_seconds = max_previous_overlap_seconds + self.collision_retries = collision_retries + self.request_timeout = request_timeout + self.max_connections = max_connections + self._daemon_instance_id = uuid.uuid4().hex + self._listener: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._lifecycle_lock = threading.RLock() + self._state_lock = threading.Lock() + self._state: Optional[Dict[str, Any]] = None + self._clients_lock = threading.Lock() + self._active_clients = set() + self._client_threads = set() + + @property + def listener_address(self) -> Tuple[str, int]: + if self._listener is None: + raise RemoteBridgeError("bridge is not running") + return self._listener.getsockname() + + @property + def active_client_count(self) -> int: + with self._clients_lock: + return len(self._active_clients) + + @property + def worker_count(self) -> int: + with self._clients_lock: + return len(self._client_threads) + + def _bind_listener(self) -> socket.socket: + last_error = None + for attempt in range(self.collision_retries + 1): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + listener.setsockopt( + socket.SOL_SOCKET, + socket.SO_EXCLUSIVEADDRUSE, + 1, + ) + try: + candidate = self.requested_port if attempt == 0 else 0 + listener.bind((LOOPBACK, candidate)) + listener.listen(16) + listener.settimeout(0.1) + return listener + except OSError as error: + last_error = error + listener.close() + if attempt == self.collision_retries or not _is_collision(error): + raise + raise last_error or RemoteBridgeError("bridge listener failed") + + def _new_state(self, generation: int) -> Dict[str, Any]: + now = time.time() + return { + "schema_version": SCHEMA_VERSION, + "protocol_version": PROTOCOL_VERSION, + "daemon_instance_id": self._daemon_instance_id, + "generation": generation, + "host": LOOPBACK, + "port": self.listener_address[1], + "capability": _capability(), + "issued_at": now, + "expires_at": now + self.ttl_seconds, + } + + def start(self) -> None: + with self._lifecycle_lock: + self._start() + + def _start(self) -> None: + if self._listener is not None: + raise RemoteBridgeError("bridge is already running") + try: + observed_state = self.state_store.read() + except FileNotFoundError: + observed_state = None + for _ in range(3): + listener = None + try: + with self.state_store.transaction(): + try: + current_state = self.state_store.read() + except FileNotFoundError: + current_state = None + records_match = ( + current_state is None + and observed_state is None + ) or ( + current_state is not None + and observed_state is not None + and self.state_store._record_matches( + current_state, + observed_state, + ) + ) + if not records_match: + observed_state = current_state + continue + if ( + current_state is not None + and current_state["expires_at"] > time.time() + ): + raise RemoteBridgeError("bridge is already running") + generation = ( + 1 + if current_state is None + else current_state["generation"] + 1 + ) + listener = self._bind_listener() + self._listener = listener + state = self._new_state(generation) + if not self.state_store.write_if_matches( + current_state, + state, + ): + listener.close() + self._listener = None + observed_state = self.state_store.read() + continue + with self._state_lock: + self._state = state + self._stop.clear() + self._thread = threading.Thread( + target=self._serve, + args=(listener,), + name="remote-bridge", + daemon=True, + ) + self._thread.start() + return + except BaseException: + if listener is not None and self._listener is listener: + listener.close() + self._listener = None + raise + raise RemoteBridgeError("bridge state publication conflicted") + + def rotate(self, *, overlap_seconds: Optional[float] = None) -> Dict[str, Any]: + with self._lifecycle_lock: + return self._rotate(overlap_seconds=overlap_seconds) + + def _rotate(self, *, overlap_seconds: Optional[float] = None) -> Dict[str, Any]: + if self._listener is None: + raise RemoteBridgeError("bridge is not running") + requested_overlap = ( + self.previous_overlap_seconds + if overlap_seconds is None + else overlap_seconds + ) + if ( + not _is_finite_number(requested_overlap) + or requested_overlap < 0 + ): + raise ValueError("overlap_seconds must be finite and nonnegative") + overlap = min(requested_overlap, self.max_previous_overlap_seconds) + with self._state_lock: + current = self._state + if current is None: + raise RemoteBridgeError("bridge state is unavailable") + with self.state_store.transaction(): + try: + published = self.state_store.read() + except FileNotFoundError as error: + raise RemoteBridgeError( + "bridge state is unavailable" + ) from error + if not self.state_store._record_matches(published, current): + raise RemoteBridgeError("bridge state ownership changed") + now = time.time() + if now >= current["expires_at"]: + raise RemoteBridgeError("bridge state expired") + state = self._new_state(current["generation"] + 1) + if overlap: + state["previous"] = { + "generation": current["generation"], + "capability": current["capability"], + "expires_at": min( + current["expires_at"], + now + overlap, + ), + } + if not self.state_store.write_if_matches(current, state): + raise RemoteBridgeError("bridge state ownership changed") + self._state = state + return dict(state) + + def _serve(self, listener: socket.socket) -> None: + while not self._stop.is_set(): + try: + connection, _ = listener.accept() + except socket.timeout: + continue + except OSError: + break + if self._stop.is_set(): + connection.close() + continue + worker = threading.Thread( + target=self._handle, + args=(connection,), + name="remote-bridge-client", + daemon=True, + ) + with self._clients_lock: + if ( + self._stop.is_set() + or len(self._active_clients) >= self.max_connections + ): + reject = True + else: + reject = False + self._active_clients.add(connection) + self._client_threads.add(worker) + if reject: + connection.close() + continue + try: + worker.start() + except BaseException: + with self._clients_lock: + self._client_threads.discard(worker) + self._active_clients.discard(connection) + connection.close() + raise + + def _credentials_valid( + self, + state: Dict[str, Any], + capability: Any, + generation: Any, + ) -> str: + if time.time() >= state["expires_at"]: + return "expired_capability" + if ( + _is_capability(capability) + and isinstance(generation, int) + and not isinstance(generation, bool) + and generation == state["generation"] + and hmac.compare_digest(capability, state["capability"]) + ): + return "" + previous = state.get("previous") + if ( + isinstance(previous, dict) + and time.time() < previous["expires_at"] + and _is_capability(capability) + and isinstance(generation, int) + and not isinstance(generation, bool) + and generation == previous["generation"] + and hmac.compare_digest(capability, previous["capability"]) + ): + return "" + return "invalid_capability" + + def _send_error(self, connection: socket.socket, code: str) -> None: + try: + send_frame(connection, {"ok": False, "error": code}) + except (OSError, RemoteBridgeError): + pass + + def _handle(self, connection: socket.socket) -> None: + stop = threading.Event() + send_lock = threading.Lock() + try: + try: + connection.settimeout(self.request_timeout) + except OSError: + return + with socket.create_connection( + self.backend_address, + timeout=min(self.request_timeout, 1.0), + ) as backend: + backend.settimeout(None) + authenticated = False + + def relay_backend() -> None: + try: + while not stop.is_set(): + response = read_frame(backend) + with send_lock: + send_frame(connection, response) + except (OSError, RemoteBridgeError): + stop.set() + + backend_thread = threading.Thread( + target=relay_backend, + name="remote-bridge-backend-relay", + daemon=True, + ) + backend_thread.start() + try: + while not stop.is_set(): + frame_deadline = ( + None + if authenticated + else time.monotonic() + self.request_timeout + ) + try: + message = read_frame(connection, deadline=frame_deadline) + except FrameTooLarge: + with send_lock: + self._send_error(connection, "frame_too_large") + return + except RemoteBridgeError: + with send_lock: + self._send_error(connection, "invalid_frame") + return + except (OSError, TimeoutError): + return + if not isinstance(message, dict) or "request" not in message: + with send_lock: + self._send_error(connection, "invalid_request") + return + with self._state_lock: + state = dict(self._state or {}) + error = self._credentials_valid( + state, + message.get("capability"), + message.get("generation"), + ) + if error: + with send_lock: + self._send_error(connection, error) + return + send_frame(backend, {"request": message["request"]}) + authenticated = True + connection.settimeout(None) + finally: + stop.set() + try: + backend.shutdown(socket.SHUT_RDWR) + except OSError: + pass + backend_thread.join(timeout=1) + except (OSError, RemoteBridgeError, TimeoutError): + with send_lock: + self._send_error(connection, "backend_unavailable") + return + finally: + try: + connection.shutdown(socket.SHUT_WR) + except OSError: + pass + connection.close() + with self._clients_lock: + self._active_clients.discard(connection) + self._client_threads.discard(threading.current_thread()) + + def stop(self) -> None: + with self._lifecycle_lock: + self._stop_bridge() + + def _stop_bridge(self) -> None: + with self._clients_lock: + has_clients = bool(self._active_clients) + if self._listener is None and self._thread is None and not has_clients: + return + self._stop.set() + listener = self._listener + self._listener = None + if listener is not None: + listener.close() + server_thread = self._thread + self._thread = None + with self._clients_lock: + clients = list(self._active_clients) + client_threads = list(self._client_threads) + for client in clients: + client.close() + if server_thread is not None: + server_thread.join(timeout=1) + current_thread = threading.current_thread() + join_deadline = time.monotonic() + 1.0 + for client_thread in client_threads: + if client_thread is not current_thread: + remaining = join_deadline - time.monotonic() + if remaining <= 0: + break + client_thread.join(timeout=remaining) + with self._state_lock: + state = self._state + self._state = None + if state is not None: + try: + self.state_store.remove_if_matches(state) + except ( + OSError, + ValueError, + json.JSONDecodeError, + RemoteBridgeError, + ): + pass + + +class RemoteBridgeClient: + """One-shot POSIX-compatible state-reader and framed request fixture.""" + + def __init__(self, state_path: Path | str, *, timeout: float = 2.0): + self.state_store = BridgeStateStore(state_path) + self.timeout = timeout + + def request( + self, + body: Any, + *, + capability: Optional[str] = None, + generation: Optional[int] = None, + ) -> Dict[str, Any]: + state = self.state_store.read() + if time.time() >= state["expires_at"]: + raise RemoteBridgeError("bridge state expired") + with socket.create_connection( + (state["host"], state["port"]), + timeout=self.timeout, + ) as connection: + connection.settimeout(self.timeout) + send_frame( + connection, + { + "capability": ( + state["capability"] + if capability is None + else capability + ), + "generation": ( + state["generation"] + if generation is None + else generation + ), + "request": body, + }, + ) + return read_frame(connection) diff --git a/investigation/spikes/remote-bridge/remote_client.py b/investigation/spikes/remote-bridge/remote_client.py new file mode 100644 index 00000000..6c4736dc --- /dev/null +++ b/investigation/spikes/remote-bridge/remote_client.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""POSIX-compatible one-shot client fixture for the remote bridge proof.""" + +import json +import sys +from pathlib import Path + +from remote_bridge import RemoteBridgeClient + + +def main() -> int: + if len(sys.argv) != 3: + print( + "usage: remote_client.py STATE.json REQUEST.json", + file=sys.stderr, + ) + return 2 + state_path = Path(sys.argv[1]) + request = json.loads(sys.argv[2]) + response = RemoteBridgeClient(state_path).request(request) + print(json.dumps(response, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/investigation/spikes/remote-bridge/run_tests.py b/investigation/spikes/remote-bridge/run_tests.py new file mode 100644 index 00000000..730c7553 --- /dev/null +++ b/investigation/spikes/remote-bridge/run_tests.py @@ -0,0 +1,23 @@ +import os +import sys +import threading +import unittest +from pathlib import Path + + +root = Path(__file__).resolve().parent +suite = unittest.defaultTestLoader.discover(str(root), pattern="test_*.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) +leaked_threads = [ + thread.name + for thread in threading.enumerate() + if thread is not threading.current_thread() and not thread.daemon +] +if leaked_threads: + print( + f"non-daemon test threads remained: {', '.join(leaked_threads)}", + file=sys.stderr, + ) +sys.stdout.flush() +sys.stderr.flush() +os._exit(0 if result.wasSuccessful() and not leaked_threads else 1) diff --git a/investigation/spikes/remote-bridge/test_remote_bridge.py b/investigation/spikes/remote-bridge/test_remote_bridge.py new file mode 100644 index 00000000..ebf30420 --- /dev/null +++ b/investigation/spikes/remote-bridge/test_remote_bridge.py @@ -0,0 +1,770 @@ +import json +import math +import os +import socket +import stat +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + + +SPIKE_ROOT = Path(__file__).resolve().parent +TIMEOUT_MULTIPLIER = float( + os.environ.get("GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER", "1") +) +if not math.isfinite(TIMEOUT_MULTIPLIER) or not 1 <= TIMEOUT_MULTIPLIER <= 10: + raise ValueError( + "GRAPHCODE_REMOTE_BRIDGE_TEST_TIMEOUT_MULTIPLIER must be between 1 and 10" + ) +SOCKET_TIMEOUT = 1.0 * TIMEOUT_MULTIPLIER +THREAD_TIMEOUT = 5.0 * TIMEOUT_MULTIPLIER +sys.path.insert(0, str(SPIKE_ROOT)) + +from remote_bridge import ( # noqa: E402 + BridgeStateStore, + FramedBackend, + RemoteBridge, + RemoteBridgeClient, + RemoteBridgeError, + read_frame, + send_frame, +) + + +class RemoteBridgeTests(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory( + prefix="graphcode-remote-bridge-" + ) + self.state_path = Path(self.test_dir.name) / "bridge-state.json" + self.backend = FramedBackend() + self.backend.start() + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=30.0, + previous_overlap_seconds=0.4, + request_timeout=2.0 * TIMEOUT_MULTIPLIER, + ) + self.bridge.start() + + def tearDown(self): + self.bridge.stop() + self.backend.stop() + self.test_dir.cleanup() + + def raw_request( + self, + state, + body, + capability=None, + generation=None, + omit_capability=False, + ): + with socket.create_connection( + (state["host"], state["port"]), timeout=SOCKET_TIMEOUT + ) as connection: + message = { + "generation": generation or state["generation"], + "request": body, + } + if not omit_capability: + message["capability"] = capability or state["capability"] + send_frame( + connection, + message, + ) + return read_frame(connection) + + def test_state_is_versioned_random_and_loopback_only(self): + state = BridgeStateStore(self.state_path).read() + + self.assertEqual(state["schema_version"], 1) + self.assertEqual(state["protocol_version"], 1) + self.assertEqual(state["host"], "127.0.0.1") + self.assertEqual(len(state["capability"]), 64) + self.assertNotEqual(state["capability"], "0" * 64) + self.assertGreater(state["expires_at"], state["issued_at"]) + self.assertEqual(self.bridge.listener_address[0], "127.0.0.1") + if os.name != "nt": + self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode) & 0o077, 0) + + def test_slow_drip_connections_are_bounded_by_cumulative_deadline(self): + self.bridge.stop() + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + request_timeout=0.15, + max_connections=2, + ) + self.bridge.start() + state = BridgeStateStore(self.state_path).read() + connections = [] + try: + for _ in range(2): + connection = socket.create_connection( + (state["host"], state["port"]), + timeout=SOCKET_TIMEOUT, + ) + connection.sendall(b"\0") + connections.append(connection) + deadline = time.time() + SOCKET_TIMEOUT + while self.bridge.active_client_count < 2: + if time.time() >= deadline: + self.fail("bounded client workers did not start") + time.sleep(0.01) + + rejected = socket.create_connection( + (state["host"], state["port"]), + timeout=SOCKET_TIMEOUT, + ) + rejected.settimeout(SOCKET_TIMEOUT) + try: + self.assertEqual(rejected.recv(1), b"") + finally: + rejected.close() + + time.sleep(0.08) + for connection in connections: + try: + connection.sendall(b"\0") + except OSError: + pass + expiry = time.monotonic() + 0.35 * TIMEOUT_MULTIPLIER + while self.bridge.active_client_count: + if time.monotonic() >= expiry: + self.fail("slow-drip worker exceeded cumulative deadline plus scheduler margin") + time.sleep(0.005) + self.assertEqual(self.bridge.active_client_count, 0) + stop_started = time.monotonic() + self.bridge.stop() + self.assertLess( + time.monotonic() - stop_started, + 1.0 * TIMEOUT_MULTIPLIER, + ) + self.assertEqual(self.bridge.worker_count, 0) + finally: + for connection in connections: + connection.close() + self.bridge.stop() + + def test_stop_closes_active_client_sockets(self): + state = BridgeStateStore(self.state_path).read() + connection = socket.create_connection( + (state["host"], state["port"]), + timeout=SOCKET_TIMEOUT, + ) + connection.sendall(b"\0") + deadline = time.time() + SOCKET_TIMEOUT + while self.bridge.active_client_count < 1: + if time.time() >= deadline: + connection.close() + self.fail("client worker did not start") + time.sleep(0.01) + + self.bridge.stop() + connection.settimeout(SOCKET_TIMEOUT) + try: + result = connection.recv(1) + except ConnectionResetError: + result = b"" + self.assertEqual(result, b"") + self.assertEqual(self.bridge.active_client_count, 0) + connection.close() + + def test_ttl_must_be_finite(self): + for ttl_seconds in (math.nan, math.inf, -math.inf): + with self.subTest(ttl_seconds=ttl_seconds): + with self.assertRaises(ValueError): + RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=ttl_seconds, + ) + + def test_state_timestamps_must_be_finite(self): + state = BridgeStateStore(self.state_path).read() + for field in ("issued_at", "expires_at"): + for value in (math.nan, math.inf, -math.inf): + with self.subTest(field=field, value=value): + invalid_path = self.state_path.with_name( + f"invalid-{field}-{str(value)}.json" + ) + invalid_state = dict(state) + invalid_state[field] = value + invalid_path.write_text( + json.dumps(invalid_state), + encoding="utf-8", + ) + try: + with self.assertRaises(RemoteBridgeError): + BridgeStateStore(invalid_path).read() + finally: + invalid_path.unlink(missing_ok=True) + for value in (math.nan, math.inf, -math.inf): + with self.subTest(previous_expires_at=value): + invalid_path = self.state_path.with_name( + f"invalid-previous-{str(value)}.json" + ) + invalid_state = dict(state) + invalid_state["previous"] = { + "generation": state["generation"], + "capability": state["capability"], + "expires_at": value, + } + invalid_path.write_text( + json.dumps(invalid_state), + encoding="utf-8", + ) + try: + with self.assertRaises(RemoteBridgeError): + BridgeStateStore(invalid_path).read() + finally: + invalid_path.unlink(missing_ok=True) + + def test_client_reads_state_and_bridges_framed_request_response(self): + client = RemoteBridgeClient( + self.state_path, + timeout=2.0 * TIMEOUT_MULTIPLIER, + ) + + response = client.request({"command": "status"}) + + self.assertEqual(response["ok"], True) + self.assertEqual(response["backend"], "named-pipe-like") + self.assertEqual(response["echo"], {"command": "status"}) + + def test_authenticated_session_relays_multiple_frames_on_one_connection(self): + state = BridgeStateStore(self.state_path).read() + with socket.create_connection( + (state["host"], state["port"]), timeout=SOCKET_TIMEOUT + ) as connection: + bodies = ({"command": "openProject"}, {"command": "status"}) + for body in bodies[:1]: + send_frame( + connection, + { + "capability": state["capability"], + "generation": state["generation"], + "request": body, + }, + ) + response = read_frame(connection) + self.assertEqual(response["ok"], True) + self.assertEqual(response["echo"], body) + self.backend.broadcast({"event": "graphChanged", "revision": 2}) + self.assertEqual( + read_frame(connection), + {"event": "graphChanged", "revision": 2}, + ) + time.sleep(self.bridge.request_timeout + 0.1) + body = bodies[1] + send_frame( + connection, + { + "capability": state["capability"], + "generation": state["generation"], + "request": body, + }, + ) + response = read_frame(connection) + self.assertEqual(response["ok"], True) + self.assertEqual(response["echo"], body) + + def test_posix_client_fixture_reads_state_and_round_trips(self): + result = subprocess.run( + [ + sys.executable, + "-B", + str(SPIKE_ROOT / "remote_client.py"), + str(self.state_path), + json.dumps({"command": "fixture"}), + ], + capture_output=True, + check=True, + text=True, + ) + + self.assertEqual( + json.loads(result.stdout), + { + "backend": "named-pipe-like", + "echo": {"command": "fixture"}, + "ok": True, + }, + ) + + def test_invalid_capability_is_rejected(self): + state = BridgeStateStore(self.state_path).read() + + response = self.raw_request( + state, + {"command": "status"}, + capability="f" * 64, + ) + + self.assertEqual(response, {"ok": False, "error": "invalid_capability"}) + + def test_malformed_capabilities_are_rejected(self): + state = BridgeStateStore(self.state_path).read() + + for capability in ("g" * 64, "A" * 64, "a" * 63, "é" * 64): + with self.subTest(capability=capability): + response = self.raw_request( + state, + {"command": "status"}, + capability=capability, + ) + + self.assertEqual( + response, + {"ok": False, "error": "invalid_capability"}, + ) + + def test_rotation_keeps_daemon_identity_while_generation_increments(self): + old_state = BridgeStateStore(self.state_path).read() + + new_state = self.bridge.rotate(overlap_seconds=0.2) + + self.assertEqual( + new_state["daemon_instance_id"], + old_state["daemon_instance_id"], + ) + self.assertEqual( + new_state["generation"], + old_state["generation"] + 1, + ) + + def test_missing_capability_is_rejected(self): + state = BridgeStateStore(self.state_path).read() + + response = self.raw_request( + state, + {"command": "status"}, + omit_capability=True, + ) + + self.assertEqual(response, {"ok": False, "error": "invalid_capability"}) + + def test_malformed_frame_is_rejected(self): + state = BridgeStateStore(self.state_path).read() + payload = b"not-json" + with socket.create_connection( + (state["host"], state["port"]), timeout=SOCKET_TIMEOUT + ) as connection: + connection.sendall(len(payload).to_bytes(4, "big") + payload) + response = read_frame(connection) + + self.assertEqual(response, {"ok": False, "error": "invalid_frame"}) + + def test_stop_preserves_a_replacement_state_record(self): + store = self.bridge.state_store + old_state = store.read() + replacement = dict(old_state) + replacement["daemon_instance_id"] = "replacement-daemon" + replacement["generation"] = old_state["generation"] + 1 + replacement["capability"] = "c" * 64 + read_started = threading.Event() + replacement_done = threading.Event() + original_read = store.read + read_count = 0 + + def interleaving_read(): + nonlocal read_count + state = original_read() + if read_count == 0: + read_count += 1 + read_started.set() + time.sleep(0.1) + return state + + store.read = interleaving_read + + def replace_state(): + read_started.wait(THREAD_TIMEOUT) + store.write(replacement) + replacement_done.set() + + replacement_thread = threading.Thread(target=replace_state, daemon=True) + replacement_thread.start() + self.bridge.stop() + replacement_thread.join(THREAD_TIMEOUT) + + self.assertFalse(replacement_thread.is_alive()) + self.assertTrue(replacement_done.is_set()) + self.assertEqual(store.read(), replacement) + + def test_expired_capability_is_rejected(self): + self.bridge.stop() + self.backend.stop() + self.backend = FramedBackend() + self.backend.start() + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=0.1, + ) + self.bridge.start() + state = BridgeStateStore(self.state_path).read() + time.sleep(0.15) + + response = self.raw_request(state, {"command": "status"}) + + self.assertEqual(response, {"ok": False, "error": "expired_capability"}) + + def test_rotation_allows_only_bounded_previous_generation(self): + old_state = BridgeStateStore(self.state_path).read() + new_state = self.bridge.rotate(overlap_seconds=0.4) + + self.assertEqual(new_state["generation"], old_state["generation"] + 1) + self.assertEqual( + self.raw_request(old_state, {"command": "old"})["ok"], + True, + ) + self.assertEqual( + self.raw_request(new_state, {"command": "new"})["ok"], + True, + ) + self.assertEqual( + new_state["previous"]["generation"], old_state["generation"] + ) + self.assertLessEqual( + new_state["previous"]["expires_at"], + time.time() + 0.5, + ) + + time.sleep(0.45) + expired_response = self.raw_request(old_state, {"command": "old"}) + + self.assertEqual( + expired_response, + {"ok": False, "error": "invalid_capability"}, + ) + + def test_restart_replaces_state_and_client_rediscoveries(self): + client = RemoteBridgeClient(self.state_path) + old_state = BridgeStateStore(self.state_path).read() + self.assertTrue(client.request({"command": "before"})["ok"]) + + self.bridge.stop() + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=3.0, + ) + self.bridge.start() + new_state = BridgeStateStore(self.state_path).read() + + self.assertNotEqual( + old_state["daemon_instance_id"], new_state["daemon_instance_id"] + ) + self.assertNotEqual(old_state["capability"], new_state["capability"]) + self.assertTrue(client.request({"command": "after"})["ok"]) + self.assertEqual( + self.raw_request( + new_state, + {"command": "stale"}, + capability=old_state["capability"], + generation=old_state["generation"], + ), + {"ok": False, "error": "invalid_capability"}, + ) + + def test_rotation_overlap_is_bounded(self): + state = self.bridge.rotate(overlap_seconds=999.0) + + self.assertLessEqual( + state["previous"]["expires_at"], + time.time() + 5.1, + ) + + def test_expired_replaced_bridge_cannot_rotate_state(self): + old_bridge = self.bridge + old_state = BridgeStateStore(self.state_path).read() + old_state["expires_at"] = old_state["issued_at"] + 0.01 + BridgeStateStore(self.state_path).write(old_state) + time.sleep(0.02) + replacement = RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=3.0, + ) + replacement.start() + replacement_state = BridgeStateStore(self.state_path).read() + self.bridge = replacement + try: + with self.assertRaises(RemoteBridgeError): + old_bridge.rotate(overlap_seconds=0.2) + self.assertEqual( + BridgeStateStore(self.state_path).read(), + replacement_state, + ) + finally: + old_bridge.stop() + + def test_rotation_overlap_starts_after_state_lock_release(self): + lock_ready = threading.Event() + release_lock = threading.Event() + rotation_done = threading.Event() + rotation_errors = [] + release_at = None + + def hold_state_lock(): + with self.bridge.state_store.transaction(): + lock_ready.set() + release_lock.wait(THREAD_TIMEOUT) + + def rotate(): + try: + self.bridge.rotate(overlap_seconds=0.15) + except BaseException as error: + rotation_errors.append(error) + finally: + rotation_done.set() + + holder = threading.Thread(target=hold_state_lock, daemon=True) + holder.start() + lock_ready.wait(THREAD_TIMEOUT) + rotation = threading.Thread(target=rotate, daemon=True) + rotation.start() + time.sleep(0.2) + self.assertFalse(rotation_done.is_set()) + release_at = time.time() + release_lock.set() + holder.join(THREAD_TIMEOUT) + rotation.join(THREAD_TIMEOUT) + + self.assertFalse(holder.is_alive()) + self.assertFalse(rotation.is_alive()) + self.assertEqual(rotation_errors, []) + state = BridgeStateStore(self.state_path).read() + self.assertGreaterEqual(state["issued_at"], release_at) + self.assertGreater( + state["previous"]["expires_at"] - state["issued_at"], + 0.1, + ) + + def test_overlap_configuration_must_be_finite_and_nonnegative(self): + for value in (math.nan, math.inf, -math.inf, -0.1): + with self.subTest(maximum=value): + with self.assertRaises(ValueError): + RemoteBridge( + self.state_path, + self.backend.address, + max_previous_overlap_seconds=value, + ) + with self.subTest(default=value): + with self.assertRaises(ValueError): + RemoteBridge( + self.state_path, + self.backend.address, + previous_overlap_seconds=value, + ) + with self.subTest(rotation=value): + with self.assertRaises(ValueError): + self.bridge.rotate(overlap_seconds=value) + + def test_concurrent_start_and_stop_has_single_owner(self): + self.bridge.stop() + bridges = [ + RemoteBridge(self.state_path, self.backend.address), + RemoteBridge(self.state_path, self.backend.address), + ] + read_barrier = threading.Barrier(len(bridges)) + start_barrier = threading.Barrier(len(bridges) + 1) + outcomes = [] + original_reads = [bridge.state_store.read for bridge in bridges] + read_count_lock = threading.Lock() + missing_reads = 0 + + def synchronized_missing(read): + def read_state(): + nonlocal missing_reads + try: + return read() + except FileNotFoundError: + with read_count_lock: + wait_for_readers = missing_reads < len(bridges) + if wait_for_readers: + missing_reads += 1 + if wait_for_readers: + read_barrier.wait(THREAD_TIMEOUT) + raise + + return read_state + + for bridge, read in zip(bridges, original_reads): + bridge.state_store.read = synchronized_missing(read) + + def start_bridge(bridge): + start_barrier.wait(THREAD_TIMEOUT) + try: + bridge.start() + outcomes.append((bridge, "started")) + except RemoteBridgeError: + outcomes.append((bridge, "rejected")) + + threads = [ + threading.Thread(target=start_bridge, args=(bridge,), daemon=True) + for bridge in bridges + ] + for thread in threads: + thread.start() + start_barrier.wait(THREAD_TIMEOUT) + for thread in threads: + thread.join(THREAD_TIMEOUT) + + self.assertTrue(all(not thread.is_alive() for thread in threads)) + started = [bridge for bridge, result in outcomes if result == "started"] + rejected = [ + bridge for bridge, result in outcomes if result == "rejected" + ] + try: + self.assertEqual(len(started), 1) + self.assertEqual(len(rejected), 1) + self.assertEqual( + BridgeStateStore(self.state_path).read()["daemon_instance_id"], + started[0]._state["daemon_instance_id"], + ) + finally: + for bridge in bridges: + bridge.stop() + + def test_stop_waits_for_start_publication(self): + self.bridge.stop() + publication_started = threading.Event() + release_publication = threading.Event() + stop_done = threading.Event() + errors = [] + store = self.bridge.state_store + original_publish = store.write_if_matches + + def delayed_publish(expected, state): + publication_started.set() + release_publication.wait(THREAD_TIMEOUT) + return original_publish(expected, state) + + store.write_if_matches = delayed_publish + + def start_bridge(): + try: + self.bridge.start() + except BaseException as error: + errors.append(error) + + def stop_bridge(): + try: + self.bridge.stop() + except BaseException as error: + errors.append(error) + finally: + stop_done.set() + + starter = threading.Thread(target=start_bridge, daemon=True) + stopper = threading.Thread(target=stop_bridge, daemon=True) + starter.start() + publication_started.wait(THREAD_TIMEOUT) + stopper.start() + time.sleep(0.1) + self.assertFalse(stop_done.is_set()) + release_publication.set() + starter.join(THREAD_TIMEOUT) + stopper.join(THREAD_TIMEOUT) + + self.assertFalse(starter.is_alive()) + self.assertFalse(stopper.is_alive()) + self.assertEqual(errors, []) + self.assertTrue(stop_done.is_set()) + self.assertEqual(self.bridge.active_client_count, 0) + with self.assertRaises(FileNotFoundError): + BridgeStateStore(self.state_path).read() + + def test_requested_port_collision_retries_with_ephemeral_port(self): + self.bridge.stop() + occupied = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + occupied.bind(("127.0.0.1", 0)) + occupied.listen(1) + requested_port = occupied.getsockname()[1] + try: + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + port=requested_port, + collision_retries=2, + ) + self.bridge.start() + state = BridgeStateStore(self.state_path).read() + + self.assertNotEqual(state["port"], requested_port) + self.assertEqual(state["host"], "127.0.0.1") + finally: + occupied.close() + + def test_atomic_replacement_never_exposes_partial_json(self): + self.bridge.stop() + self.bridge = RemoteBridge( + self.state_path, + self.backend.address, + ttl_seconds=60.0, + ) + self.bridge.start() + errors = [] + stop_readers = threading.Event() + + def read_states(): + store = BridgeStateStore(self.state_path) + while not stop_readers.is_set(): + try: + state = store.read() + if state["schema_version"] != 1: + errors.append("wrong schema") + except (OSError, json.JSONDecodeError, RemoteBridgeError) as error: + errors.append(str(error)) + time.sleep(0.005) + + readers = [ + threading.Thread(target=read_states, daemon=True) for _ in range(3) + ] + for reader in readers: + reader.start() + try: + for _ in range(40): + self.bridge.rotate(overlap_seconds=0.1) + finally: + stop_readers.set() + for reader in readers: + reader.join(THREAD_TIMEOUT) + + self.assertTrue(all(not reader.is_alive() for reader in readers)) + self.assertEqual(errors, []) + + def test_oversized_frame_is_rejected(self): + state = BridgeStateStore(self.state_path).read() + with socket.create_connection( + (state["host"], state["port"]), timeout=SOCKET_TIMEOUT + ) as connection: + connection.sendall((1_048_577).to_bytes(4, "big")) + response = read_frame(connection) + + self.assertEqual(response, {"ok": False, "error": "frame_too_large"}) + + def test_missing_backend_returns_sanitized_error(self): + self.bridge.stop() + dead_backend = self.backend.address + self.backend.stop() + self.bridge = RemoteBridge(self.state_path, dead_backend) + self.bridge.start() + client = RemoteBridgeClient(self.state_path) + + response = client.request({"command": "status"}) + + self.assertEqual(response, {"ok": False, "error": "backend_unavailable"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/investigation/spikes/remote-e2e/__init__.py b/investigation/spikes/remote-e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/investigation/spikes/remote-e2e/posix_client.py b/investigation/spikes/remote-e2e/posix_client.py new file mode 100644 index 00000000..eb2334b5 --- /dev/null +++ b/investigation/spikes/remote-e2e/posix_client.py @@ -0,0 +1,30 @@ +"""Remote command client executed by POSIX sshd through the reverse forward.""" + +import json +import socket +import sys + + +def recv_exact(sock, count): + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise EOFError("unexpected EOF while reading frame") + data.extend(chunk) + return bytes(data) + + +def main(): + host, port = "127.0.0.1", int(sys.argv[1]) + payload = json.loads(sys.stdin.buffer.read()) + with socket.create_connection((host, port), timeout=5) as sock: + data = json.dumps(payload, separators=(",", ":")).encode() + sock.sendall(len(data).to_bytes(4, "big") + data) + size = int.from_bytes(recv_exact(sock, 4), "big") + response = recv_exact(sock, size) + print(json.dumps(json.loads(response))) + + +if __name__ == "__main__": + main() diff --git a/investigation/spikes/remote-e2e/posix_fixture_server.py b/investigation/spikes/remote-e2e/posix_fixture_server.py new file mode 100644 index 00000000..00d98282 --- /dev/null +++ b/investigation/spikes/remote-e2e/posix_fixture_server.py @@ -0,0 +1,120 @@ +"""Small persistent POSIX daemon used only by the OpenSSH E2E fixture.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import threading +import uuid +from pathlib import Path + + +def recv_exact(sock, count): + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise EOFError("unexpected EOF while reading frame") + data.extend(chunk) + return bytes(data) + + +def frame_read(sock): + size = int.from_bytes(recv_exact(sock, 4), "big") + data = recv_exact(sock, size) + return json.loads(data) + + +def frame_write(sock, value): + data = json.dumps(value, separators=(",", ":")).encode() + sock.sendall(len(data).to_bytes(4, "big") + data) + + +class Server: + def __init__(self, state_path, port, reboot): + self.path = Path(state_path) + self.lock = threading.Lock() + self.state = self._load() + if reboot: + self.state["boot_id"] = uuid.uuid4().hex + self._save() + self.listener = socket.socket() + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(("127.0.0.1", port)) + self.listener.listen(16) + + def _load(self): + if self.path.exists(): + return json.loads(self.path.read_text()) + state = {"boot_id": uuid.uuid4().hex, "projects": {}, "events": []} + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(state)) + return state + + def _save(self): + temporary = self.path.with_suffix(".tmp") + temporary.write_text(json.dumps(self.state, sort_keys=True)) + os.replace(temporary, self.path) + + def dispatch(self, request): + key = f"{request.get('host')}/{request.get('project')}" + with self.lock: + if request.get("command") == "boot": + return {"boot_id": self.state["boot_id"]} + if request.get("command") == "events": + return {"events": self.state["events"]} + if request.get("command") == "setup": + self.state["projects"].setdefault(key, {"nodes": [], "messages": []}) + self._save() + return {"host": request["host"], "project": request["project"]} + project = self.state["projects"].get(key) + if project is None: + return {"error": "project_not_found"} + if request.get("command") == "create": + if request["node"] not in project["nodes"]: + project["nodes"].append(request["node"]) + self.state["events"].append(f"{key}:{request['node']}") + self._save() + return {"id": request["node"]} + if request.get("command") == "send": + project["messages"].append(request["message"]) + self._save() + return {"delivered": True} + if request.get("command") == "status": + return {"nodes": project["nodes"], "messages": project["messages"]} + return {"error": "unknown_command"} + + def serve(self): + while True: + connection, _ = self.listener.accept() + threading.Thread(target=self.handle, args=(connection,), daemon=True).start() + + def handle(self, connection): + try: + while True: + envelope = frame_read(connection) + frame_write(connection, {"ok": True, **self.dispatch(envelope.get("request", {}))}) + except (OSError, ValueError, json.JSONDecodeError): + connection.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--state", required=True) + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--reboot", action="store_true") + parser.add_argument("--pid-file") + parser.add_argument("--port-file") + args = parser.parse_args() + if args.pid_file: + Path(args.pid_file).write_text(str(os.getpid())) + server = Server(args.state, args.port, args.reboot) + if args.port_file: + Path(args.port_file).write_text(str(server.listener.getsockname()[1])) + server.serve() + + +if __name__ == "__main__": + main() diff --git a/investigation/spikes/remote-e2e/remote_e2e_fixture.py b/investigation/spikes/remote-e2e/remote_e2e_fixture.py new file mode 100644 index 00000000..ee0e99fa --- /dev/null +++ b/investigation/spikes/remote-e2e/remote_e2e_fixture.py @@ -0,0 +1,440 @@ +"""Authenticated Windows OpenSSH reverse-forward parity harness.""" + +from __future__ import annotations + +import json +import os +import secrets +import re +import shutil +import subprocess +import sys +import tempfile +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +BRIDGE = ROOT.parent / "remote-bridge" +sys.path.insert(0, str(BRIDGE)) +from remote_bridge import BridgeStateStore, RemoteBridge, RemoteBridgeError # noqa: E402 + + +def wsl_path(path): + windows_path = str(path).replace("\\", "/") + result = subprocess.run(["wsl.exe", "wslpath", "-a", windows_path], + capture_output=True, text=True, check=True) + return result.stdout.strip() + + +@dataclass(frozen=True) +class ExternalTarget: + user: str + host: str + port: int | None + + @classmethod + def parse(cls, value): + if "@" in value: + user, value = value.rsplit("@", 1) + if not user: + raise ValueError("target user is empty") + else: + user = None + if value.startswith("["): + end = value.find("]") + if end < 0: + raise ValueError("invalid IPv6 target") + host = value[1:end] + suffix = value[end + 1:] + if suffix == "": + port = None + elif re.fullmatch(r":[0-9]+", suffix): + port = int(suffix[1:]) + else: + raise ValueError("invalid IPv6 port suffix") + elif value.count(":") == 1: + host, raw_port = value.rsplit(":", 1) + port = int(raw_port) + else: + host, port = value, None + if not host or (port is not None and not 1 <= port <= 65535): + raise ValueError("invalid target") + return cls(user, host, port) + + def argv(self): + known_hosts = os.environ.get("GRAPHCODE_REMOTE_E2E_KNOWN_HOSTS", "NUL") + args = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ExitOnForwardFailure=yes", "-o", + f"UserKnownHostsFile={known_hosts}", "-o", "GlobalKnownHostsFile=none"] + if self.port is not None: + args += ["-p", str(self.port)] + rendered_host = f"[{self.host}]" if ":" in self.host else self.host + args.append(f"{self.user}@{rendered_host}" if self.user else rendered_host) + args.append("true") + return args + + def authenticated_probe(self): + return subprocess.run(self.argv(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, timeout=10).returncode == 0 + + +def external_targets(): + raw = os.environ.get("GRAPHCODE_REMOTE_E2E_TARGETS") + if raw is None: + return [] + if not os.environ.get("GRAPHCODE_REMOTE_E2E_KNOWN_HOSTS"): + raise ValueError("GRAPHCODE_REMOTE_E2E_KNOWN_HOSTS is required with external targets") + values = [item.strip() for item in raw.split(",")] + if not values or any(not item for item in values): + raise ValueError("GRAPHCODE_REMOTE_E2E_TARGETS must contain non-empty targets") + return [ExternalTarget.parse(item) for item in values] + + +class LocalRemoteParityFixture: + def __init__(self): + self.directory = Path(tempfile.mkdtemp(prefix="graphcode-remote-e2e-")) + self.state_path = self.directory / "bridge-state.json" + self.posix_state = self.directory / "posix-state.json" + self.server_script = wsl_path(ROOT / "posix_fixture_server.py") + self.client_script = wsl_path(ROOT / "posix_client.py") + self.wsl_state = wsl_path(self.posix_state) + self.ssh_home = f"/home/{subprocess.run(['wsl.exe', 'id', '-un'], capture_output=True, text=True, check=True).stdout.strip()}/.graphcode-remote-e2e-{uuid.uuid4().hex}" + self.wsl_authorized = f"{self.ssh_home}/authorized_keys" + self.wsl_host_key = f"{self.ssh_home}/host_key" + self.wsl_config = f"{self.ssh_home}/sshd_config" + self.wsl_server_pid = f"{self.ssh_home}/server.pid" + self.wsl_sshd_pid = f"{self.ssh_home}/sshd.pid" + self.ssh_port = 45000 + secrets.randbelow(10000) + self.remote_forward_port = self.ssh_port + 1 + self.ssh_host = subprocess.run(["wsl.exe", "hostname", "-I"], + capture_output=True, text=True, check=True).stdout.split()[0] + self.bridge = None + self.server_process = None + self.sshd_process = None + self.tunnel_process = None + self.old_capability = "" + self.old_generation = 0 + self.last_diagnostic = "" + self.safe_error = "remote bridge authentication failed" + self.default_known_hosts = Path(os.environ.get("USERPROFILE", "")) / ".ssh" / "known_hosts" + self.default_known_hosts_snapshot = ( + self.default_known_hosts.read_bytes() if self.default_known_hosts.exists() else None + ) + + @property + def capability(self): + return BridgeStateStore(self.state_path).read()["capability"] + + @property + def generation(self): + return BridgeStateStore(self.state_path).read()["generation"] + + @property + def boot_id(self): + return self._remote({"command": "boot"})["boot_id"] + + @property + def ssh_arguments(self): + return ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ExitOnForwardFailure=yes", "-R", + f"127.0.0.1:{self.remote_forward_port}:127.0.0.1:{self.bridge.listener_address[1]}"] + + def _run_wsl(self, args, **kwargs): + return subprocess.Popen(["wsl.exe", "sh", "-lc", "exec " + " ".join(args)], **kwargs) + + def start(self, reboot=False): + try: + self._prepare_ssh() + self._start_server(reboot) + self.bridge = RemoteBridge(self.state_path, ("127.0.0.1", 0), + ttl_seconds=30, previous_overlap_seconds=0) + # The POSIX process is intentionally the bridge backend. + self.bridge.backend_address = ("127.0.0.1", self.server_port) + self.bridge.start() + self._start_sshd() + self._start_tunnel() + except BaseException: + try: + self._cleanup_all() + except BaseException as cleanup_error: + raise RuntimeError(f"fixture startup and cleanup failed: {cleanup_error}") from cleanup_error + raise + + def _prepare_ssh(self): + key = self.directory / "client_key" + if not key.exists(): + subprocess.run(["ssh-keygen.exe", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, stdout=subprocess.DEVNULL) + subprocess.run(["wsl.exe", "sh", "-lc", f"mkdir -p {self.ssh_home} && chmod 700 {self.ssh_home} && test -f {self.wsl_host_key} || ssh-keygen -q -t ed25519 -N '' -f {self.wsl_host_key}"], + check=True, stdout=subprocess.DEVNULL) + with key.with_suffix(".pub").open("rb") as public_key: + subprocess.run(["wsl.exe", "sh", "-lc", f"cat > {self.wsl_authorized} && chmod 600 {self.wsl_authorized}"], + stdin=public_key, check=True, stdout=subprocess.DEVNULL) + user = subprocess.run(["wsl.exe", "id", "-un"], capture_output=True, text=True, check=True).stdout.strip() + self.ssh_user = user + self.client_key = key + self.known_hosts = self.directory / "known_hosts" + config = ( + f"Port {self.ssh_port}\nListenAddress 0.0.0.0\nHostKey {self.wsl_host_key}\n" + f"AuthorizedKeysFile {self.wsl_authorized}\nStrictModes no\nPasswordAuthentication no\n" + f"PubkeyAuthentication yes\nUsePAM no\nPermitRootLogin no\nAllowUsers {user}\n" + f"PidFile {self.wsl_sshd_pid}\n" + ) + subprocess.run(["wsl.exe", "sh", "-lc", f"cat > {self.wsl_config} && chmod 600 {self.wsl_config}"], + input=config, text=True, check=True, stdout=subprocess.DEVNULL) + self._write_known_hosts() + + def _write_known_hosts(self): + public = subprocess.run(["wsl.exe", "ssh-keygen", "-y", "-f", self.wsl_host_key], + capture_output=True, text=True, check=True).stdout.strip() + self.known_hosts.write_text(f"[{self.ssh_host}]:{self.ssh_port} {public}\n") + + def _start_server(self, reboot): + port_file = f"{self.ssh_home}/server-port" + subprocess.run(["wsl.exe", "rm", "-f", port_file], check=True) + args = ["python3", self.server_script, "--state", self.wsl_state, + "--port", "0", "--pid-file", self.wsl_server_pid, "--port-file", port_file] + if reboot: + args.append("--reboot") + self.server_process = self._run_wsl(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self._wait_file(port_file) + self.server_port = int(subprocess.run(["wsl.exe", "cat", port_file], + capture_output=True, text=True, check=True).stdout) + self._wait_server_protocol(self.server_port) + + def _start_sshd(self): + for _ in range(5): + self.sshd_process = self._run_wsl(["/usr/sbin/sshd", "-D", "-e", "-f", self.wsl_config], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + self._wait_ssh() + return + except RuntimeError as error: + if self.sshd_process.poll() is None or "Address already in use" not in str(error): + raise + self.sshd_process.wait(timeout=5) + self.ssh_port += 1 + self.remote_forward_port = self.ssh_port + 1 + self._write_known_hosts() + subprocess.run(["wsl.exe", "sed", "-i", f"s/^Port .*/Port {self.ssh_port}/", self.wsl_config], + check=True) + raise RuntimeError("could not allocate an OpenSSH fixture port") + + def _start_tunnel(self): + args = ["ssh.exe", "-i", str(self.client_key), "-p", str(self.ssh_port), + "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", f"UserKnownHostsFile={self.known_hosts}", + "-o", "ExitOnForwardFailure=yes", "-N", "-R", + f"127.0.0.1:{self.remote_forward_port}:127.0.0.1:{self.bridge.listener_address[1]}", + f"{self.ssh_user}@{self.ssh_host}"] + self.tunnel_process = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(0.5) + if self.tunnel_process.poll() is not None: + raise RuntimeError("OpenSSH reverse tunnel failed") + + def _wait_server_protocol(self, port): + import socket + deadline = time.time() + 10 + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=.2) as sock: + payload = json.dumps({"request": {"command": "boot"}}).encode() + sock.sendall(len(payload).to_bytes(4, "big") + payload) + header = self._recv_exact(sock, 4) + size = int.from_bytes(header, "big") + response = json.loads(self._recv_exact(sock, size)) + if response.get("ok") and response.get("boot_id"): + return + except OSError: + time.sleep(.05) + except (EOFError, ValueError, json.JSONDecodeError): + time.sleep(.05) + raise RuntimeError(f"POSIX fixture protocol did not start on port {port}") + + @staticmethod + def _recv_exact(sock, count): + data = bytearray() + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise EOFError("unexpected EOF") + data.extend(chunk) + return bytes(data) + + def _wait_file(self, path): + deadline = time.time() + 10 + while time.time() < deadline: + result = subprocess.run(["wsl.exe", "test", "-s", path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0: + return + if self.server_process.poll() is not None: + raise RuntimeError("POSIX fixture exited before publishing its port") + time.sleep(.05) + raise RuntimeError(f"fixture did not publish {path}") + + def _wait_ssh(self): + deadline = time.time() + 10 + while time.time() < deadline: + if self.sshd_process.poll() is not None: + error = self.sshd_process.stderr.read() + raise RuntimeError(f"fixture sshd exited: {error}") + result = subprocess.run(["ssh.exe", "-i", str(self.client_key), "-p", str(self.ssh_port), + "-o", "StrictHostKeyChecking=yes", + "-o", f"UserKnownHostsFile={self.known_hosts}", + "-o", "BatchMode=yes", "-o", "ConnectTimeout=1", + f"{self.ssh_user}@{self.ssh_host}", "true"], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + if result.returncode == 0: + return + time.sleep(.1) + raise RuntimeError(f"fixture sshd did not start: {result.stderr.strip()}") + + def _remote(self, request, capability=None, generation=None): + capability = self.capability if capability is None else capability + generation = self.generation if generation is None else generation + result = subprocess.run( + ["ssh.exe", "-i", str(self.client_key), "-p", str(self.ssh_port), + "-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={self.known_hosts}", + "-o", "BatchMode=yes", + f"{self.ssh_user}@{self.ssh_host}", "python3", self.client_script, + str(self.remote_forward_port)], + input=json.dumps({"capability": capability, "generation": generation, + "request": request}, separators=(",", ":")), + capture_output=True, text=True, check=False, timeout=10, + ) + if result.returncode: + raise RuntimeError(f"remote ssh command failed: {result.stderr.strip()}") + response = json.loads(result.stdout) + self.last_diagnostic = json.dumps(response, sort_keys=True) + return response + + def _request(self, command, **fields): + return self._remote({"command": command, **fields}) + + def assert_capability_not_in_process_metadata(self): + secret = self.capability + if self.tunnel_process: + query = ( + "Get-CimInstance Win32_Process -Filter " + f"'ProcessId={self.tunnel_process.pid}' | Select-Object -Expand CommandLine" + ) + command_line = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", query], + capture_output=True, text=True, check=True, + ).stdout + if secret in command_line: + raise AssertionError("capability leaked into Windows process metadata") + ps = subprocess.run( + ["wsl.exe", "sh", "-lc", "ps -eo args"], + capture_output=True, text=True, check=True, + ).stdout + if secret in ps: + raise AssertionError("capability leaked into POSIX process metadata") + for pid_path in (self.wsl_server_pid, self.wsl_sshd_pid): + pid = subprocess.run(["wsl.exe", "cat", pid_path], + capture_output=True, text=True, check=False).stdout.strip() + if pid.isdigit(): + command_line = subprocess.run( + ["wsl.exe", "cat", f"/proc/{pid}/cmdline"], + capture_output=True, text=True, check=False, + ).stdout + if secret in command_line: + raise AssertionError("capability leaked into /proc command metadata") + + def setup_project(self, host, project): return self._request("setup", host=host, project=project) + def create_node(self, host, project, node): return self._request("create", host=host, project=project, node=node) + def send_message(self, host, project, node, message): + return self._request("send", host=host, project=project, node=node, message=message) + def status(self, host, project): return self._request("status", host=host, project=project) + def fanout_events(self): return self._request("events")["events"] + + def rotate(self): + state = BridgeStateStore(self.state_path).read() + self.old_capability, self.old_generation = state["capability"], state["generation"] + self.bridge.rotate(overlap_seconds=0) + + def unauthorized(self, capability, generation=None): + response = self._remote({"command": "status"}, capability=capability, + generation=self.generation if generation is None else generation) + self.last_diagnostic = json.dumps(response, sort_keys=True) + return response.get("error", "") + + def reconnect(self): + previous = BridgeStateStore(self.state_path).read() + self._stop_processes() + stale = dict(previous) + stale["issued_at"], stale["expires_at"] = time.time() - 2, time.time() - 1 + BridgeStateStore(self.state_path).write(stale) + self.start() + + def reboot(self): + previous = BridgeStateStore(self.state_path).read() + self._stop_processes() + stale = dict(previous) + stale["issued_at"], stale["expires_at"] = time.time() - 2, time.time() - 1 + BridgeStateStore(self.state_path).write(stale) + self.start(reboot=True) + + def _stop_processes(self): + if self.bridge: + self.bridge.stop() + for pid_path in (self.wsl_server_pid, self.wsl_sshd_pid): + result = subprocess.run(["wsl.exe", "cat", pid_path], capture_output=True, text=True, check=False) + if result.returncode == 0 and result.stdout.strip().isdigit(): + linux_pid = result.stdout.strip() + subprocess.run(["wsl.exe", "kill", "-TERM", linux_pid], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + deadline = time.time() + 5 + while time.time() < deadline: + if subprocess.run(["wsl.exe", "kill", "-0", linux_pid], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + break + time.sleep(.05) + else: + subprocess.run(["wsl.exe", "kill", "-KILL", linux_pid], check=False) + if subprocess.run(["wsl.exe", "kill", "-0", linux_pid], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: + raise RuntimeError(f"WSL fixture PID {linux_pid} did not terminate") + for process in (self.tunnel_process, self.sshd_process, self.server_process): + if process and process.poll() is None: + process.kill() + process.wait(timeout=5) + if process: + for stream in (process.stdout, process.stderr): + if stream: + stream.close() + self.tunnel_process = self.sshd_process = self.server_process = None + marker = self.ssh_home.replace("'", "''") + cleanup = ( + "$marker='{0}'; $self=$PID; " + "Get-CimInstance Win32_Process | " + "Where-Object {{ $_.ProcessId -ne $self -and $_.CommandLine -like " + "\"*$marker*\" }} | " + "ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}" + ).format(marker) + subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", cleanup], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + def _cleanup_all(self): + self._stop_processes() + subprocess.run(["wsl.exe", "rm", "-rf", self.ssh_home], check=True) + if subprocess.run(["wsl.exe", "test", "!", "-e", self.ssh_home], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + raise AssertionError("WSL SSH fixture directory was not removed") + if self.directory.exists(): + shutil.rmtree(self.directory) + if self.directory.exists(): + raise AssertionError("Windows SSH fixture directory was not removed") + current = self.default_known_hosts.read_bytes() if self.default_known_hosts.exists() else None + if current != self.default_known_hosts_snapshot: + raise AssertionError("fixture modified the user's default known_hosts") + + def stop(self): + self._cleanup_all() diff --git a/investigation/spikes/remote-e2e/test_remote_e2e.py b/investigation/spikes/remote-e2e/test_remote_e2e.py new file mode 100644 index 00000000..ee40cd67 --- /dev/null +++ b/investigation/spikes/remote-e2e/test_remote_e2e.py @@ -0,0 +1,176 @@ +"""Executable Windows-to-POSIX remote parity checks. + +The local fixture is mandatory by default. Public Windows runners without a WSL +distribution may explicitly pass --skip-local-wsl; real hosts remain opt-in. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +import unittest + +from remote_e2e_fixture import ExternalTarget, LocalRemoteParityFixture, external_targets +from posix_client import recv_exact as client_recv_exact +from posix_fixture_server import recv_exact as server_recv_exact + +SKIP_LOCAL_WSL = "--skip-local-wsl" in sys.argv +if SKIP_LOCAL_WSL: + sys.argv.remove("--skip-local-wsl") + + +class RemoteParityTests(unittest.TestCase): + def setUp(self): + self.fixture = None + if self._testMethodName.startswith("test_local"): + if SKIP_LOCAL_WSL: + self.skipTest("local WSL fixture explicitly disabled") + self.fixture = LocalRemoteParityFixture() + self.fixture.start() + + def tearDown(self): + if self.fixture: + self.fixture.stop() + + def test_local_setup_fanout_messaging_and_capability_isolation(self): + project = self.fixture.setup_project("host-a", "alpha") + self.assertEqual(project["host"], "host-a") + self.assertEqual(self.fixture.setup_project("host-b", "beta")["host"], "host-b") + self.assertEqual(self.fixture.create_node("host-a", "alpha", "n1")["id"], "n1") + self.assertEqual(self.fixture.create_node("host-b", "beta", "n1")["id"], "n1") + self.assertEqual( + self.fixture.send_message("host-a", "alpha", "n1", "hello")["delivered"], + True, + ) + self.assertEqual(self.fixture.status("host-a", "alpha")["messages"], ["hello"]) + self.assertEqual(self.fixture.status("host-b", "beta")["messages"], []) + self.assertEqual(self.fixture.fanout_events(), ["host-a/alpha:n1", "host-b/beta:n1"]) + self.assertNotIn(self.fixture.capability, self.fixture.last_diagnostic) + self.fixture.assert_capability_not_in_process_metadata() + + def test_local_reconnect_restart_and_reboot_restore_state(self): + self.fixture.setup_project("host-a", "alpha") + self.fixture.create_node("host-a", "alpha", "n1") + before = self.fixture.generation + server_before = self.fixture.server_process.pid + self.fixture.reconnect() + self.assertNotEqual(self.fixture.server_process.pid, server_before) + self.assertGreater(self.fixture.generation, before) + self.assertEqual(self.fixture.status("host-a", "alpha")["nodes"], ["n1"]) + boot_before = self.fixture.boot_id + server_before = self.fixture.server_process.pid + self.fixture.reboot() + self.assertNotEqual(self.fixture.boot_id, boot_before) + self.assertNotEqual(self.fixture.server_process.pid, server_before) + self.assertEqual(self.fixture.status("host-a", "alpha")["nodes"], ["n1"]) + self.assertGreater(self.fixture.generation, before) + + def test_local_authentication_generation_and_no_capability_leakage(self): + self.fixture.setup_project("host-a", "alpha") + self.assertEqual(self.fixture.unauthorized("wrong"), "invalid_capability") + old_generation = self.fixture.generation + self.fixture.rotate() + self.assertGreater(self.fixture.generation, old_generation) + self.assertEqual( + self.fixture.unauthorized( + self.fixture.old_capability, self.fixture.old_generation + ), + "invalid_capability", + ) + self.assertEqual( + self.fixture.unauthorized( + self.fixture.old_capability, self.fixture.generation + ), + "invalid_capability", + ) + self.assertEqual( + self.fixture.unauthorized( + self.fixture.capability, self.fixture.old_generation + ), + "invalid_capability", + ) + time.sleep(0.05) + self.assertNotIn(self.fixture.capability, self.fixture.last_diagnostic) + self.assertNotIn("capability", self.fixture.safe_error) + + def test_local_ssh_fixture_requires_authenticated_loopback_forward(self): + args = self.fixture.ssh_arguments + self.assertIn("StrictHostKeyChecking=yes", args) + self.assertIn("ExitOnForwardFailure=yes", args) + forward = args[args.index("-R") + 1] + self.assertRegex(forward, r"^127\.0\.0\.1:\d+:127\.0\.0\.1:\d+$") + + def test_external_target_parser_places_port_before_destination_and_supports_ipv6(self): + valid = (("dev@[::1]:2222", "2222", "dev@[::1]"), ("[2001:db8::1]", None, "[2001:db8::1]")) + for value, expected_port, expected_destination in valid: + with self.subTest(value=value): + target = ExternalTarget.parse(value) + self.assertEqual(target.argv()[-2], expected_destination) + if expected_port: + self.assertEqual(target.argv()[target.argv().index("-p") + 1], expected_port) + + def test_bracketed_ipv6_parser_rejects_invalid_port_suffixes(self): + for value in ("dev@[::1]garbage", "dev@[::1]:", "dev@[::1]:0", + "dev@[::1]:65536", "dev@[::1]:abc"): + with self.subTest(value=value): + with self.assertRaises(ValueError): + ExternalTarget.parse(value) + + @unittest.skipIf(SKIP_LOCAL_WSL, "local WSL fixture explicitly disabled") + def test_start_failure_removes_all_fixture_directories(self): + fixture = LocalRemoteParityFixture() + windows_directory = fixture.directory + wsl_directory = fixture.ssh_home + fixture._start_tunnel = lambda: (_ for _ in ()).throw( + RuntimeError("forced tunnel failure") + ) + with self.assertRaises(RuntimeError): + fixture.start() + self.assertFalse(windows_directory.exists()) + self.assertEqual( + subprocess.run( + ["wsl.exe", "test", "!", "-e", wsl_directory] + ).returncode, 0, + ) + fixture.stop() + + def test_external_target_configuration_rejects_empty_entries(self): + old = os.environ.get("GRAPHCODE_REMOTE_E2E_TARGETS") + try: + os.environ["GRAPHCODE_REMOTE_E2E_TARGETS"] = "," + with self.assertRaises(ValueError): + external_targets() + finally: + if old is None: + os.environ.pop("GRAPHCODE_REMOTE_E2E_TARGETS", None) + else: + os.environ["GRAPHCODE_REMOTE_E2E_TARGETS"] = old + + def test_fragmented_headers_and_eof_are_handled_by_both_posix_helpers(self): + for recv in (client_recv_exact, server_recv_exact): + left, right = socket.socketpair() + try: + left.sendall(b"\x00") + left.sendall(b"\x00\x00\x03") + self.assertEqual(recv(right, 4), b"\x00\x00\x00\x03") + left.close() + with self.assertRaises(EOFError): + recv(right, 1) + finally: + right.close() + + @unittest.skipUnless( + os.environ.get("GRAPHCODE_REMOTE_E2E_TARGETS"), + "set GRAPHCODE_REMOTE_E2E_TARGETS for authenticated external POSIX hosts", + ) + def test_configured_external_targets(self): + for target in external_targets(): + if not target.authenticated_probe(): + self.fail(f"configured external POSIX target failed: {target}") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/investigation/spikes/swift-contracts/Package.resolved b/investigation/spikes/swift-contracts/Package.resolved new file mode 100644 index 00000000..e85545b9 --- /dev/null +++ b/investigation/spikes/swift-contracts/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "4d5a42cc3b072aaf8ea8a4ff2bd830e690da39f70bbc97bbe9be8c1420f216eb", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/investigation/spikes/swift-contracts/Package.swift b/investigation/spikes/swift-contracts/Package.swift new file mode 100644 index 00000000..f2cff97b --- /dev/null +++ b/investigation/spikes/swift-contracts/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeWindowsContractsSpike", + products: [ + .library(name: "GraphcodeWindowsContracts", targets: ["GraphcodeWindowsContracts"]) + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + .target( + name: "GraphcodeWindowsContracts", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "Sources/GraphcodeWindowsContracts", + exclude: [ + "Domain/BackendCommand.swift", + "Domain/SessionBriefing.swift", + "IPC/DaemonSocketClient.swift", + ], + sources: [ + "Domain", + "IPC", + "Platform", + "QuickChatStore.swift", + "SupportDirectory.swift", + ]), + .testTarget( + name: "GraphcodeWindowsContractsTests", + dependencies: ["GraphcodeWindowsContracts"]), + ]) diff --git a/investigation/spikes/swift-contracts/Tests/GraphcodeWindowsContractsTests/ContractTests.swift b/investigation/spikes/swift-contracts/Tests/GraphcodeWindowsContractsTests/ContractTests.swift new file mode 100644 index 00000000..85263e05 --- /dev/null +++ b/investigation/spikes/swift-contracts/Tests/GraphcodeWindowsContractsTests/ContractTests.swift @@ -0,0 +1,1218 @@ +import Foundation + +import GraphcodeWindowsContracts + +import XCTest + +final class ContractTests: XCTestCase { + func testDeployedV1CommandStillDecodes() throws { + let data = Data(#"{"listRecentProjects":{}}"#.utf8) + + XCTAssertEqual( + try DaemonWireProtocol.decodeClientFrame(data), + .v1(.listRecentProjects)) + } + + func testFrozenV1AppAndPythonShimCommandsStillDecode() throws { + let appCommand = Data(#"{"openProject":{"path":"C:\\Projects\\Demo"}}"#.utf8) + let shimCommand = Data(#"{"openGlobalGraph":{}}"#.utf8) + + XCTAssertEqual( + try DaemonWireProtocol.decodeClientFrame(appCommand), + .v1(.openProject(path: #"C:\Projects\Demo"#))) + XCTAssertEqual( + try DaemonWireProtocol.decodeClientFrame(shimCommand), + .v1(.openGlobalGraph)) + } + + func testV2RequestRoundTripsWithCorrelation() throws { + let requestID = UUID() + let envelope = DaemonWireEnvelope.request( + id: requestID, + command: .openProject(path: #"C:\Projects\Demo"#)) + + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(DaemonWireEnvelope.self, from: data) + + XCTAssertEqual(decoded, envelope) + XCTAssertEqual(try decoded.validated(), envelope) + } + + func testNegotiationChoosesHighestMutualVersion() throws { + let hello = DaemonWireEnvelope.hello(supportedVersions: [1, 2]) + + XCTAssertEqual(try DaemonWireProtocol.negotiatedVersion(for: hello), 2) + } + + func testResponseRequiresRequestID() { + let invalid = DaemonWireEnvelope( + version: 2, + kind: .response, + event: .errorOccurred("missing correlation")) + + XCTAssertThrowsError(try invalid.validated()) + } + + func testCorrelatedSuccessResponseAllowsNoPayload() throws { + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000022")! + let success = DaemonWireEnvelope.success(id: requestID) + + XCTAssertEqual(try success.validated(), success) + XCTAssertEqual(success.kind, .response) + XCTAssertEqual(success.requestID, requestID) + XCTAssertNil(success.event) + XCTAssertEqual(success.success, true) + } + + func testV1ListRecentProjectsKeepsLegacyEventShape() async throws { + let projects = [ + ProjectRef(path: "/work/listed", name: "listed") + ] + let event = DaemonEvent.recentProjectsListed(projects) + let transport = RecordingConnection() + let channel = DaemonConnectionChannel(connection: transport, mode: .v1) + + try await channel.sendEvent(event) + + let frame = try XCTUnwrap(transport.frames.first) + XCTAssertEqual(try JSONDecoder().decode(DaemonEvent.self, from: frame), event) + XCTAssertThrowsError(try JSONDecoder().decode(DaemonWireEnvelope.self, from: frame)) + } + + func testV2ListResponseIsSingleCorrelatedFrameAndNeverReplayed() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000035")! + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000036")! + let event = DaemonEvent.recentProjectsListed([ + ProjectRef(path: "/work/listed", name: "listed") + ]) + let store = DaemonReplayStore(capacity: 8) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + + try await firstChannel.sendResponse(requestID: requestID, event: event) + + XCTAssertEqual(firstTransport.frames.count, 1) + let response = try JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(firstTransport.frames.first)) + XCTAssertEqual(response.kind, .response) + XCTAssertEqual(response.requestID, requestID) + XCTAssertNil(response.sequence) + guard case .recentProjectsListed(let listed) = response.event else { + return XCTFail("expected recent-projects response event") + } + XCTAssertEqual(listed.first?.path, "/work/listed") + + try await firstChannel.close() + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await reconnectChannel.replay(after: 0) + XCTAssertTrue(reconnectTransport.frames.isEmpty) + } + + func testVersionOneEnvelopeIsRejected() { + let invalid = DaemonWireEnvelope( + version: 1, + kind: .hello, + supportedVersions: [1, 2]) + + XCTAssertThrowsError(try invalid.validated()) + } + + func testRequestRejectsFieldsFromAnotherEnvelopeKind() { + let invalid = DaemonWireEnvelope( + version: 2, + kind: .request, + requestID: UUID(), + command: .listRecentProjects, + event: .errorOccurred("not a request field")) + + XCTAssertThrowsError(try invalid.validated()) + } + + func testFrameHeaderRejectsOversizedPayload() { + let header: [UInt8] = [0x7f, 0xff, 0xff, 0xff] + + XCTAssertThrowsError( + try DaemonFrameHeader.decodeLength( + header, maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes)) + } + + func testLegacySafetyCeilingRejectsUInt32MaximumBeforeAllocation() { + XCTAssertThrowsError( + try DaemonFrameHeader.decodeLength( + [0xff, 0xff, 0xff, 0xff], + maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes)) + } + + func testFrameHeaderRoundTripsAllowedPayload() throws { + let encoded = try DaemonFrameHeader.encodeLength(64 * 1024) + + XCTAssertEqual(try DaemonFrameHeader.decodeLength(Array(encoded)), 64 * 1024) + } + + func testFrameHeaderPreservesLegacyUInt32PayloadRange() throws { + let length = 2 * 1_048_576 + let encoded = try DaemonFrameHeader.encodeLength(length) + + XCTAssertEqual(try DaemonFrameHeader.decodeLength(Array(encoded)), length) + XCTAssertEqual( + try DaemonFrameHeader.decodeLength([0xff, 0xff, 0xff, 0xff]), + Int(UInt32.max)) + } + + func testFramingRoundTripsThroughAByteStream() async throws { + let writer = MemoryByteStream() + try await FramedMessageIO.writeFrame(Data("hello".utf8), to: writer) + + let reader = MemoryByteStream(input: writer.output) + let frame = try await FramedMessageIO.readFrame(from: reader) + XCTAssertEqual(frame, Data("hello".utf8)) + } + + func testFramingRejectsOversizedPayloadBeforeWriting() async { + let writer = MemoryByteStream() + do { + try await FramedMessageIO.writeFrame( + Data(repeating: 0, count: FramedMessageIO.v2MaxPayloadBytes + 1), + to: writer, + maxPayloadBytes: FramedMessageIO.v2MaxPayloadBytes) + XCTFail("expected oversized payload to be rejected") + } catch FramedMessageIO.IOError.payloadTooLarge { + XCTAssertTrue(writer.output.isEmpty) + } catch { + XCTFail("unexpected framing error: \(error)") + } + } + + func testLegacyV1CommandAndEventFramesExceedTheV2Cap() async throws { + let largeText = String( + repeating: "x", + count: Int(DaemonFrameHeader.legacySafetyCeilingBytes) - 1_024) + let command = DaemonCommand.openProject(path: largeText) + let commandData = try JSONEncoder().encode(command) + XCTAssertGreaterThan(commandData.count, FramedMessageIO.v2MaxPayloadBytes) + XCTAssertLessThanOrEqual( + commandData.count, Int(DaemonFrameHeader.legacySafetyCeilingBytes)) + + let commandWriter = MemoryByteStream() + try await FramedMessageIO.writeFrame(commandData, to: commandWriter) + let commandReader = MemoryByteStream(input: commandWriter.output) + let decodedCommand = try await FramedMessageIO.readFrame(from: commandReader) + XCTAssertEqual(try DaemonWireProtocol.decodeClientFrame(decodedCommand), .v1(command)) + + let event = DaemonEvent.errorOccurred(largeText) + let eventData = try JSONEncoder().encode(event) + XCTAssertGreaterThan(eventData.count, FramedMessageIO.v2MaxPayloadBytes) + XCTAssertLessThanOrEqual( + eventData.count, Int(DaemonFrameHeader.legacySafetyCeilingBytes)) + let eventWriter = MemoryByteStream() + try await FramedMessageIO.writeFrame(eventData, to: eventWriter) + let eventReader = MemoryByteStream(input: eventWriter.output) + let decodedEvent = try await FramedMessageIO.readFrame(from: eventReader) + XCTAssertEqual(try JSONDecoder().decode(DaemonEvent.self, from: decodedEvent), event) + } + + func testOversizedV2EnvelopeIsRejectedAfterEnvelopeIdentification() throws { + let largePath = String(repeating: "x", count: FramedMessageIO.v2MaxPayloadBytes) + let envelope = DaemonWireEnvelope.request( + id: UUID(), command: .openProject(path: largePath)) + let data = try JSONEncoder().encode(envelope) + XCTAssertGreaterThan(data.count, FramedMessageIO.v2MaxPayloadBytes) + + XCTAssertThrowsError(try DaemonWireProtocol.decodeClientFrame(data)) { error in + XCTAssertEqual( + error as? DaemonWireEnvelope.ValidationError, .payloadTooLarge) + } + } + + func testHelloCanCarrySubscriptionAndResumeCursor() throws { + let hello = DaemonWireEnvelope.hello( + supportedVersions: [1, 2], + clientID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + resumeFrom: 41, + subscription: DaemonWireSubscription(projectPaths: ["/work/demo"])) + + let decoded = try JSONDecoder().decode( + DaemonWireEnvelope.self, from: JSONEncoder().encode(hello)) + + XCTAssertEqual(decoded, hello) + XCTAssertEqual(decoded.subscription?.projectPaths, ["/work/demo"]) + XCTAssertEqual(decoded.resumeFrom, 41) + } + + private final class MemoryByteStream: @unchecked Sendable, DaemonByteStream { + private var input: Data + private var offset = 0 + private(set) var output = Data() + + init(input: Data = Data()) { + self.input = input + } + + func readExactly(_ count: Int) async throws -> Data { + guard input.count - offset >= count else { + throw FramedMessageIO.IOError.connectionClosed + } + defer { offset += count } + return input.subdata(in: offset.. Data { + throw FramedMessageIO.IOError.connectionClosed + } + + func sendFrame(_ data: Data) async throws { + await probe.begin() + try? await Task.sleep(for: sendDelay) + frames.append(data) + await probe.end() + } + + func close() async throws {} + + var maxConcurrentSends: Int { + get async { await probe.maximum } + } + } + + private final class SlowReplayConnection: @unchecked Sendable, DaemonConnection { + let id = UUID() + let endpoint: DaemonEndpoint = .namedPipe("slow-replay") + private let state = SlowReplayState() + + func receiveFrame() async throws -> Data { + throw FramedMessageIO.IOError.connectionClosed + } + + func sendFrame(_ data: Data) async throws { + let isClosed = await state.isClosed + guard !isClosed else { throw FramedMessageIO.IOError.connectionClosed } + try await Task.sleep(for: .milliseconds(5)) + guard !(await state.isClosed) else { + throw FramedMessageIO.IOError.connectionClosed + } + await state.append(data) + } + + func close() async throws { + await state.close() + } + + var isClosed: Bool { + get async { await state.isClosed } + } + } + + private actor SlowReplayState { + private(set) var isClosed = false + private(set) var frames = [Data]() + + func append(_ data: Data) { + frames.append(data) + } + + func close() { + isClosed = true + } + } + + private actor SendProbe { + private var current = 0 + private(set) var maximum = 0 + + func begin() { + current += 1 + maximum = max(maximum, current) + } + + func end() { + current -= 1 + } + } + + func testReplayBufferReturnsContiguousEvents() throws { + var buffer = DaemonReplayBuffer(capacity: 2) + buffer.append(sequence: 1, event: .errorOccurred("one")) + buffer.append(sequence: 2, event: .errorOccurred("two")) + buffer.append(sequence: 3, event: .errorOccurred("three")) + + XCTAssertEqual( + try buffer.replay(after: 1), + [ + .event(sequence: 2, event: .errorOccurred("two")), + .event(sequence: 3, event: .errorOccurred("three")), + ]) + } + + func testReplayBufferReportsCursorOutsideWindow() { + var buffer = DaemonReplayBuffer(capacity: 2) + buffer.append(sequence: 1, event: .errorOccurred("one")) + buffer.append(sequence: 2, event: .errorOccurred("two")) + buffer.append(sequence: 3, event: .errorOccurred("three")) + + XCTAssertThrowsError(try buffer.replay(after: 0)) + } + + func testReplayBufferDistinguishesCaughtUpFromCursorBeyondLatest() throws { + var buffer = DaemonReplayBuffer(capacity: 2) + buffer.append(sequence: 1, event: .errorOccurred("one")) + buffer.append(sequence: 2, event: .errorOccurred("two")) + + XCTAssertEqual(try buffer.replay(after: 2), []) + XCTAssertThrowsError(try buffer.replay(after: 3)) { error in + XCTAssertEqual( + error as? DaemonReplayBuffer.ReplayError, .cursorOutsideWindow) + } + } + + func testUnknownReplayHistoryIsUnavailable() { + let store = DaemonReplayStore(capacity: 2) + do { + _ = try store.replay(clientID: UUID(), after: 0) + XCTFail("expected unknown replay history to be unavailable") + } catch DaemonReplayBuffer.ReplayError.replayUnavailable { + // Expected after a daemon restart with no retained client history. + } catch { + XCTFail("unexpected replay error: \(error)") + } + } + + func testReplayUsesTheReconnectingChannelsCurrentSubscription() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000004")! + let store = DaemonReplayStore(capacity: 8) + let first = LoopGraph(project: ProjectRef(path: "/work/first", name: "first")) + let second = LoopGraph(project: ProjectRef(path: "/work/second", name: "second")) + _ = store.append(clientID: clientID, event: .graphChanged(first)) + _ = store.append(clientID: clientID, event: .graphChanged(second)) + + let transport = RecordingConnection() + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: ["/work/second"]), + replayStore: store) + + try await channel.replay(after: 0) + + XCTAssertEqual(transport.frames.count, 1) + let replayed = try JSONDecoder().decode( + DaemonWireEnvelope.self, from: transport.frames[0]) + guard case .graphChanged(let graph) = replayed.event else { + return XCTFail("expected a graphChanged replay") + } + XCTAssertEqual(graph.project.path, "/work/second") + } + + func testReplayQueuesLiveEventsAfterReplaySequence() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000005")! + let store = DaemonReplayStore(capacity: 8) + _ = store.append(clientID: clientID, event: .errorOccurred("replay-1")) + _ = store.append(clientID: clientID, event: .errorOccurred("replay-2")) + let transport = RecordingConnection(sendDelay: .milliseconds(20)) + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + + let replay = Task { try await channel.replay(after: 0) } + try await Task.sleep(for: .milliseconds(1)) + try await channel.sendEvent(.errorOccurred("live-3")) + try await replay.value + + let sequences = try transport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0).sequence + } + XCTAssertEqual(sequences, [1, 2, 3]) + } + + func testReplayQueueOverflowClosesSlowConnectionDuringUpdateFlood() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000045")! + let store = DaemonReplayStore(capacity: 300) + for index in 0..<300 { + _ = store.append(clientID: clientID, event: .errorOccurred("history-\(index)")) + } + let transport = SlowReplayConnection() + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + let replayTask = Task { + try? await channel.replay(after: 0) + } + try await Task.sleep(for: .milliseconds(10)) + + var overflowed = false + for index in 0..<(DaemonConnectionChannel.maxQueuedLiveEventCount + 32) { + do { + try await channel.sendEvent(.errorOccurred("update-\(index)")) + } catch DaemonConnectionChannelError.replayQueueOverflow { + overflowed = true + break + } catch { + break + } + } + + XCTAssertTrue(overflowed) + let closed = await transport.isClosed + XCTAssertTrue(closed) + _ = await replayTask.value + } + + func testReplayStoreEvictsAndExpiresClientHistory() throws { + let first = UUID(uuidString: "00000000-0000-0000-0000-000000000006")! + let second = UUID(uuidString: "00000000-0000-0000-0000-000000000007")! + let third = UUID(uuidString: "00000000-0000-0000-0000-000000000008")! + let store = DaemonReplayStore(capacity: 2, maxClients: 2, retention: 60) + + _ = store.append(clientID: first, event: .errorOccurred("first")) + _ = store.append(clientID: second, event: .errorOccurred("second")) + _ = try store.replay(clientID: second, after: 0) + _ = store.append(clientID: third, event: .errorOccurred("third")) + + XCTAssertThrowsError(try store.replay(clientID: first, after: 0)) { error in + XCTAssertEqual(error as? DaemonReplayBuffer.ReplayError, .replayUnavailable) + } + XCTAssertEqual(store.clientCount, 2) + + store.pruneExpired(at: Date().addingTimeInterval(61)) + XCTAssertEqual(store.clientCount, 0) + } + + func testActiveClientsAreNeverEvictedWhenReplayCapacityIsExhausted() throws { + let first = UUID(uuidString: "00000000-0000-0000-0000-000000000015")! + let second = UUID(uuidString: "00000000-0000-0000-0000-000000000016")! + let third = UUID(uuidString: "00000000-0000-0000-0000-000000000017")! + let firstConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000018")! + let secondConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000019")! + let thirdConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000020")! + let secondThirdConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000021")! + let store = DaemonReplayStore(capacity: 8, maxClients: 2) + + store.register(clientID: first, connectionID: firstConnection, subscription: nil) + store.register(clientID: second, connectionID: secondConnection, subscription: nil) + _ = store.append(clientID: first, event: .errorOccurred("first")) + _ = store.append(clientID: second, event: .errorOccurred("second")) + + store.register(clientID: third, connectionID: thirdConnection, subscription: nil) + let firstThirdEvent = store.append(clientID: third, event: .errorOccurred("third-1")) + store.register( + clientID: third, + connectionID: secondThirdConnection, + subscription: nil) + let secondThirdEvent = store.append(clientID: third, event: .errorOccurred("third-2")) + + XCTAssertEqual(store.clientCount, 2) + XCTAssertEqual(firstThirdEvent.sequence, 1) + XCTAssertEqual(secondThirdEvent.sequence, 2) + XCTAssertNoThrow(try store.replay(clientID: first, after: 0)) + XCTAssertNoThrow(try store.replay(clientID: second, after: 0)) + XCTAssertThrowsError(try store.replay(clientID: third, after: 0)) { error in + XCTAssertEqual(error as? DaemonReplayBuffer.ReplayError, .replayUnavailable) + } + } + + func testOverflowClientPromotionPreservesSequenceWatermarkAndReplay() async throws { + let first = UUID(uuidString: "00000000-0000-0000-0000-000000000024")! + let second = UUID(uuidString: "00000000-0000-0000-0000-000000000025")! + let overflow = UUID(uuidString: "00000000-0000-0000-0000-000000000026")! + let firstConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000027")! + let secondConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000028")! + let overflowConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000029")! + let store = DaemonReplayStore(capacity: 8, maxClients: 2) + + store.register(clientID: first, connectionID: firstConnection, subscription: nil) + store.register(clientID: second, connectionID: secondConnection, subscription: nil) + let overflowTransport = RecordingConnection(id: overflowConnection) + let overflowChannel = DaemonConnectionChannel( + connection: overflowTransport, + mode: .v2(version: 2), + clientID: overflow, + replayStore: store) + + try await overflowChannel.sendConnectionSnapshot( + .graphChanged(LoopGraph(project: ProjectRef(path: "/work/overflow", name: "overflow")))) + try await overflowChannel.sendEvent(.errorOccurred("live-before-promotion")) + let initialSequences = try overflowTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0).sequence + } + XCTAssertEqual(initialSequences, [1, 2]) + + store.disconnect(clientID: first, connectionID: firstConnection) + let canonicalGraph = LoopGraph( + project: ProjectRef(path: "/work/overflow", name: "overflow"), + nodes: [LoopNode(title: "canonical")]) + try await overflowChannel.sendEvent(.graphChanged(canonicalGraph)) + let promotedEnvelope = try XCTUnwrap( + JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(overflowTransport.frames.last))) + XCTAssertEqual(promotedEnvelope.sequence, 3) + + try await overflowChannel.close() + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: overflow, + replayStore: store) + try await reconnectChannel.replay(after: 2) + + let replayed = try reconnectTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0) + } + XCTAssertEqual(replayed.map(\.sequence), [3]) + guard case .graphChanged(let replayedGraph) = replayed.first?.event else { + return XCTFail("expected the canonical graph event in replay") + } + XCTAssertEqual(replayedGraph.project.path, canonicalGraph.project.path) + XCTAssertEqual(replayedGraph.nodes.first?.title, "canonical") + XCTAssertThrowsError(try store.replay(clientID: first, after: 0)) { error in + XCTAssertEqual(error as? DaemonReplayBuffer.ReplayError, .replayUnavailable) + } + } + + func testCanonicalAppendPromotesOverflowClientAfterInactiveEviction() throws { + let path = "/work/production-overload" + let retained = UUID(uuidString: "00000000-0000-0000-0000-000000000041")! + let retainedConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000042")! + let overflow = UUID(uuidString: "00000000-0000-0000-0000-000000000043")! + let overflowConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000044")! + let store = DaemonReplayStore(capacity: 8, maxClients: 1) + store.register( + clientID: retained, connectionID: retainedConnection, subscription: nil) + store.join(clientID: retained, connectionID: retainedConnection, projectPath: path) + let retainedGraph = LoopGraph(project: ProjectRef(path: path, name: "retained")) + let retainedEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(retainedGraph), projectPath: path)[retained]) + XCTAssertEqual(retainedEnvelope.sequence, 1) + + store.register( + clientID: overflow, connectionID: overflowConnection, subscription: nil) + store.join(clientID: overflow, connectionID: overflowConnection, projectPath: path) + store.disconnect(clientID: retained, connectionID: retainedConnection) + + let overflowGraph = LoopGraph(project: ProjectRef(path: path, name: "overflow")) + let promoted = try XCTUnwrap( + store.append(event: .graphChanged(overflowGraph), projectPath: path)[overflow]) + XCTAssertEqual(promoted.sequence, 1) + XCTAssertEqual(store.clientCount, 1) + XCTAssertEqual(try store.replay(clientID: overflow, after: 0), [promoted]) + XCTAssertThrowsError(try store.replay(clientID: retained, after: 0)) + } + + func testZeroCapacityTracksActiveSequencesAndReconnectsWithoutReplay() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000030")! + let store = DaemonReplayStore(capacity: 8, maxClients: 0) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + + try await firstChannel.sendEvent(.errorOccurred("one")) + try await firstChannel.sendConnectionSnapshot(.errorOccurred("two")) + try await firstChannel.sendEvent(.errorOccurred("three")) + + let firstSequences = try firstTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0).sequence + } + XCTAssertEqual(firstSequences, [1, 2, 3]) + XCTAssertEqual(store.clientCount, 0) + XCTAssertEqual(try store.replay(clientID: clientID, after: 3), []) + XCTAssertThrowsError(try store.replay(clientID: clientID, after: 0)) { error in + XCTAssertEqual(error as? DaemonReplayBuffer.ReplayError, .replayUnavailable) + } + + try await firstChannel.close() + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + do { + try await reconnectChannel.replay(after: 3) + XCTFail("expected reconnect cursor to be outside the new active window") + } catch DaemonConnectionChannelError.cursorOutsideWindow { + // Expected: maxClients zero drops the prior active state when disconnected. + } + + try await reconnectChannel.sendEvent(.errorOccurred("reconnected")) + let reconnectEnvelope = try XCTUnwrap( + JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(reconnectTransport.frames.first))) + XCTAssertEqual(reconnectEnvelope.sequence, 1) + } + + func testZeroCapacityBroadcastSharesOneEnvelopeAcrossLogicalSockets() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000031")! + let path = "/work/multi-socket" + let store = DaemonReplayStore(capacity: 8, maxClients: 0) + let firstTransport = RecordingConnection() + let secondTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + let secondChannel = DaemonConnectionChannel( + connection: secondTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + await firstChannel.join(projectPath: path) + await secondChannel.join(projectPath: path) + + let graph = LoopGraph(project: ProjectRef(path: path, name: "multi-socket")) + let envelope = try XCTUnwrap( + store.append(event: .graphChanged(graph), projectPath: path)[clientID]) + try await firstChannel.sendEvent(envelope: envelope) + try await secondChannel.sendEvent(envelope: envelope) + + let firstFrame = try XCTUnwrap(firstTransport.frames.first) + let secondFrame = try XCTUnwrap(secondTransport.frames.first) + let firstEnvelope = try JSONDecoder().decode(DaemonWireEnvelope.self, from: firstFrame) + let secondEnvelope = try JSONDecoder().decode(DaemonWireEnvelope.self, from: secondFrame) + XCTAssertEqual(firstEnvelope.sequence, secondEnvelope.sequence) + guard case .graphChanged(let firstGraph) = firstEnvelope.event, + case .graphChanged(let secondGraph) = secondEnvelope.event + else { + return XCTFail("expected graph events on both logical sockets") + } + XCTAssertEqual(firstGraph.id, secondGraph.id) + XCTAssertEqual(firstGraph.project.path, secondGraph.project.path) + XCTAssertEqual(firstEnvelope.sequence, 1) + } + + func testProjectMembershipSurvivesOneSocketLeaveAndReplaysAfterBothDisconnect() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000032")! + let firstConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000033")! + let secondConnection = UUID(uuidString: "00000000-0000-0000-0000-000000000034")! + let path = "/work/multi-join" + let store = DaemonReplayStore(capacity: 8, maxClients: 2) + store.register(clientID: clientID, connectionID: firstConnection, subscription: nil) + store.register(clientID: clientID, connectionID: secondConnection, subscription: nil) + store.join(clientID: clientID, connectionID: firstConnection, projectPath: path) + store.join(clientID: clientID, connectionID: secondConnection, projectPath: path) + + let firstGraph = LoopGraph(project: ProjectRef(path: path, name: "multi-join")) + let firstEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(firstGraph), projectPath: path)[clientID]) + store.leave(clientID: clientID, connectionID: firstConnection, projectPath: path) + let secondGraph = LoopGraph( + project: ProjectRef(path: path, name: "multi-join"), + nodes: [NodeDraft(title: "second", loopType: .composite).makeNode()]) + let secondEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(secondGraph), projectPath: path)[clientID]) + XCTAssertEqual(firstEnvelope.sequence, 1) + XCTAssertEqual(secondEnvelope.sequence, 2) + + store.disconnect(clientID: clientID, connectionID: firstConnection) + store.disconnect(clientID: clientID, connectionID: secondConnection) + let thirdGraph = LoopGraph( + project: ProjectRef(path: path, name: "multi-join"), + nodes: [NodeDraft(title: "third", loopType: .composite).makeNode()]) + let thirdEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(thirdGraph), projectPath: path)[clientID]) + XCTAssertEqual(thirdEnvelope.sequence, 3) + XCTAssertEqual( + try store.replay(clientID: clientID, after: 2).map(\.sequence), + [3]) + + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await reconnectChannel.replay(after: 2) + let replayed = try JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(reconnectTransport.frames.first)) + XCTAssertEqual(replayed.sequence, 3) + } + + func testReplayStoreAutomaticallyExpiresIdleHistory() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000011")! + let store = DaemonReplayStore(capacity: 2, retention: 0.01) + let cleanup = store.startCleanup(every: .milliseconds(5)) + defer { cleanup.cancel() } + _ = store.append(clientID: clientID, event: .errorOccurred("idle")) + + for _ in 0..<100 where store.clientCount != 0 { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(store.clientCount, 0) + } + + func testReplayRetainsCanonicalEventsWhileLogicalClientIsDisconnected() throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000012")! + let connectionID = UUID(uuidString: "00000000-0000-0000-0000-000000000013")! + let path = "/work/disconnected" + let store = DaemonReplayStore(capacity: 8) + store.register(clientID: clientID, connectionID: connectionID, subscription: nil) + store.join(clientID: clientID, projectPath: path) + + let first = LoopGraph(project: ProjectRef(path: path, name: "disconnected")) + let second = LoopGraph( + project: ProjectRef(path: path, name: "disconnected"), + nodes: [NodeDraft(title: "later", loopType: .composite).makeNode()]) + let firstEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(first), projectPath: path)[clientID]) + store.disconnect(clientID: clientID, connectionID: connectionID) + let secondEnvelope = try XCTUnwrap( + store.append(event: .graphChanged(second), projectPath: path)[clientID]) + + XCTAssertEqual(firstEnvelope.sequence, 1) + XCTAssertEqual(secondEnvelope.sequence, 2) + XCTAssertEqual( + try store.replay(clientID: clientID, after: 1), + [secondEnvelope]) + } + + func testRepeatedConnectionSnapshotsDoNotCreateReplayHistory() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000014")! + let store = DaemonReplayStore(capacity: 8) + let graph = LoopGraph(project: ProjectRef(path: "/work/rejoin", name: "rejoin")) + + let first = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: first, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await firstChannel.sendConnectionSnapshot(.graphChanged(graph)) + try await firstChannel.close() + + let second = RecordingConnection() + let secondChannel = DaemonConnectionChannel( + connection: second, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await secondChannel.sendConnectionSnapshot(.graphChanged(graph)) + try await secondChannel.close() + + XCTAssertThrowsError(try store.replay(clientID: clientID, after: 0)) { error in + XCTAssertEqual(error as? DaemonReplayBuffer.ReplayError, .replayUnavailable) + } + } + + func testSnapshotCursorCanResumeImmediatelyAfterDisconnect() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000023")! + let store = DaemonReplayStore(capacity: 8) + let graph = LoopGraph(project: ProjectRef(path: "/work/snapshot", name: "snapshot")) + _ = store.append(clientID: clientID, event: .errorOccurred("before-snapshot")) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + + try await firstChannel.sendConnectionSnapshot(.graphChanged(graph)) + let snapshot = try XCTUnwrap( + try JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(firstTransport.frames.first))) + let cursor = try XCTUnwrap(snapshot.sequence) + try await firstChannel.close() + + let secondTransport = RecordingConnection() + let secondChannel = DaemonConnectionChannel( + connection: secondTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await secondChannel.replay(after: cursor) + + XCTAssertTrue(secondTransport.frames.isEmpty) + } + + func testSnapshotGapEqualToNonReplayableCountStillReplaysCanonicalEvent() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000048")! + let store = DaemonReplayStore(capacity: 8) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + await firstChannel.join(projectPath: "/work/snapshot-gap") + try await firstChannel.sendConnectionSnapshot(.errorOccurred("snapshot")) + let envelope = try XCTUnwrap( + store.append( + event: .errorOccurred("canonical"), + projectPath: "/work/snapshot-gap")[clientID]) + try await firstChannel.sendEvent(envelope: envelope) + try await firstChannel.close() + + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await reconnectChannel.replay(after: 0) + + let replayed = try reconnectTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0) + } + XCTAssertEqual(replayed.map(\.sequence), [2]) + try await reconnectChannel.close() + } + + func testSnapshotGapsCompactIntoRangesAcrossRepeatedSnapshots() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000049")! + let store = DaemonReplayStore(capacity: 4) + let transport = RecordingConnection() + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + await channel.join(projectPath: "/work/repeated-snapshots") + + for _ in 0..<1_000 { + try await channel.sendConnectionSnapshot(.errorOccurred("snapshot")) + } + let envelope = try XCTUnwrap( + store.append( + event: .errorOccurred("canonical"), + projectPath: "/work/repeated-snapshots")[clientID]) + try await channel.sendEvent(envelope: envelope) + try await channel.close() + + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await reconnectChannel.replay(after: 0) + + let replayed = try reconnectTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0) + } + XCTAssertEqual(replayed.map(\.sequence), [1_001]) + try await reconnectChannel.close() + } + + func testDisconnectedSocketSubscriptionDoesNotRemainInLogicalClientUnion() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! + let pathA = "/work/disconnected-a" + let pathB = "/work/disconnected-b" + let store = DaemonReplayStore(capacity: 8) + let first = DaemonConnectionChannel( + connection: RecordingConnection(), + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: [pathA]), + replayStore: store) + await first.join(projectPath: pathA) + try await first.close() + store.join(clientID: clientID, projectPath: pathA) + + let secondTransport = RecordingConnection() + let second = DaemonConnectionChannel( + connection: secondTransport, + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: [pathB]), + replayStore: store) + await second.join(projectPath: pathB) + + XCTAssertNil( + store.append( + event: .errorOccurred("not-retained"), + projectPath: pathA)[clientID]) + XCTAssertNotNil( + store.append( + event: .errorOccurred("retained"), + projectPath: pathB)[clientID]) + try await second.close() + } + + func testSecondSocketSnapshotDoesNotInvalidateFirstSocketResumeCursor() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000046")! + let store = DaemonReplayStore(capacity: 8) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await firstChannel.sendConnectionSnapshot(.errorOccurred("first-snapshot")) + let firstSnapshot = try JSONDecoder().decode( + DaemonWireEnvelope.self, + from: XCTUnwrap(firstTransport.frames.first)) + let firstCursor = try XCTUnwrap(firstSnapshot.sequence) + + let secondChannel = DaemonConnectionChannel( + connection: RecordingConnection(), + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await secondChannel.sendConnectionSnapshot(.errorOccurred("second-snapshot")) + try await firstChannel.sendEvent(.errorOccurred("canonical-after-snapshots")) + try await firstChannel.close() + try await secondChannel.close() + + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + replayStore: store) + try await reconnectChannel.replay(after: firstCursor) + + let replayed = try reconnectTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0) + } + XCTAssertEqual(replayed.map(\.sequence), [3]) + guard case .errorOccurred("canonical-after-snapshots") = replayed.first?.event else { + return XCTFail("expected the canonical event after both snapshots") + } + } + + func testMultiSocketSubscriptionsUnionCanonicalReplayMembership() async throws { + let clientID = UUID(uuidString: "00000000-0000-0000-0000-000000000047")! + let firstPath = "/work/subscription-first" + let secondPath = "/work/subscription-second" + let store = DaemonReplayStore(capacity: 8) + let firstTransport = RecordingConnection() + let firstChannel = DaemonConnectionChannel( + connection: firstTransport, + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: [firstPath]), + replayStore: store) + await firstChannel.join(projectPath: firstPath) + + let secondChannel = DaemonConnectionChannel( + connection: RecordingConnection(), + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: [secondPath]), + replayStore: store) + await secondChannel.join(projectPath: secondPath) + + let graph = LoopGraph(project: ProjectRef(path: firstPath, name: "first")) + let envelope = try XCTUnwrap( + store.append(event: .graphChanged(graph), projectPath: firstPath)[clientID]) + try await firstChannel.sendEvent(envelope: envelope) + try await firstChannel.close() + + let reconnectTransport = RecordingConnection() + let reconnectChannel = DaemonConnectionChannel( + connection: reconnectTransport, + mode: .v2(version: 2), + clientID: clientID, + subscription: DaemonWireSubscription(projectPaths: [firstPath]), + replayStore: store) + try await reconnectChannel.replay(after: 0) + + let replayed = try reconnectTransport.frames.map { + try JSONDecoder().decode(DaemonWireEnvelope.self, from: $0) + } + XCTAssertEqual(replayed.map(\.sequence), [1]) + guard case .graphChanged(let replayedGraph) = replayed.first?.event else { + return XCTFail("expected the first socket's subscribed event") + } + XCTAssertEqual(replayedGraph.project.path, firstPath) + try await secondChannel.close() + try await reconnectChannel.close() + } + + func testMalformedV2RequestKeepsSafelyExtractableRequestID() throws { + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000009")! + var malformed = DaemonWireEnvelope.request(id: requestID, command: .listRecentProjects) + malformed.event = .errorOccurred("unexpected") + let data = try JSONEncoder().encode(malformed) + + XCTAssertThrowsError(try DaemonWireProtocol.decodeClientFrame(data)) + XCTAssertEqual(DaemonWireProtocol.requestIDIfPresent(in: data), requestID) + } + + func testWrongKindEnvelopeDoesNotBorrowItsRequestID() throws { + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000010")! + let data = try JSONEncoder().encode( + DaemonWireEnvelope.response(id: requestID, event: .errorOccurred("response"))) + + XCTAssertNil(DaemonWireProtocol.requestIDIfPresent(in: data)) + } + + func testUnmarkedInvalidInitialFrameUsesV1ErrorShape() throws { + let invalid = Data(#"{"notACommand":{}}"#.utf8) + let errorFrame = try DaemonWireProtocol.initialErrorFrame( + for: invalid, message: "invalid v1 command") + + XCTAssertFalse(DaemonWireProtocol.isV2ShapedFrame(invalid)) + XCTAssertEqual( + try JSONDecoder().decode(DaemonEvent.self, from: errorFrame), + .errorOccurred("invalid v1 command")) + } + + func testMarkedInvalidInitialFrameUsesV2ErrorShape() throws { + let invalid = Data(#"{"version":2,"kind":"response"}"#.utf8) + let errorFrame = try DaemonWireProtocol.initialErrorFrame( + for: invalid, message: "invalid v2 envelope") + let decoded = try JSONDecoder().decode(DaemonWireEnvelope.self, from: errorFrame) + + XCTAssertTrue(DaemonWireProtocol.isV2ShapedFrame(invalid)) + XCTAssertEqual(decoded.kind, .error) + XCTAssertEqual(decoded.error?.code, DaemonWireErrorCode.malformedEnvelope.rawValue) + XCTAssertEqual(decoded.error?.message, "invalid v2 envelope") + } + + func testUnsupportedInitialVersionUsesUnsupportedVersionErrorShape() throws { + let invalid = Data(#"{"version":3,"kind":"hello"}"#.utf8) + let errorFrame = try DaemonWireProtocol.initialErrorFrame( + for: invalid, message: "unsupported version") + let decoded = try JSONDecoder().decode(DaemonWireEnvelope.self, from: errorFrame) + + XCTAssertEqual(decoded.kind, .error) + XCTAssertEqual(decoded.error?.code, DaemonWireErrorCode.unsupportedVersion.rawValue) + } + + func testV2ChannelSequencesEventsAndCorrelatesResponses() async throws { + let transport = RecordingConnection() + let store = DaemonReplayStore(capacity: 8) + let channel = DaemonConnectionChannel( + connection: transport, + mode: .v2(version: 2), + clientID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + replayStore: store) + + try await channel.sendEvent(.errorOccurred("event")) + let requestID = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + try await channel.sendError( + requestID: requestID, code: .requestFailed, message: "request failed") + try await channel.sendError(code: .transportFailure, message: "broadcast failure") + try await channel.sendResponse(requestID: requestID, event: .errorOccurred("response")) + + let frames = transport.frames + XCTAssertEqual(frames.count, 4) + let event = try JSONDecoder().decode(DaemonWireEnvelope.self, from: frames[0]) + let error = try JSONDecoder().decode(DaemonWireEnvelope.self, from: frames[1]) + let broadcastError = try JSONDecoder().decode(DaemonWireEnvelope.self, from: frames[2]) + let response = try JSONDecoder().decode(DaemonWireEnvelope.self, from: frames[3]) + XCTAssertEqual(event.sequence, 1) + XCTAssertEqual(event.kind, .event) + XCTAssertEqual(error.requestID, requestID) + XCTAssertEqual(error.kind, .error) + XCTAssertNil(broadcastError.requestID) + XCTAssertEqual(response.requestID, requestID) + XCTAssertEqual(response.kind, .response) + } + + func testConcurrentChannelSendsNeverEnterTheTransportTogether() async { + let transport = RecordingConnection() + let channel = DaemonConnectionChannel( + connection: transport, mode: .v2(version: 2), replayStore: DaemonReplayStore()) + + await withTaskGroup(of: Void.self) { group in + for index in 0..<16 { + group.addTask { + try? await channel.sendEvent(.errorOccurred("event-\(index)")) + } + } + } + + let maximum = await transport.maxConcurrentSends + XCTAssertEqual(maximum, 1) + XCTAssertEqual(transport.frames.count, 16) + } + + func testChannelsSharingAConnectionSerializeCompleteWrites() async { + let transport = RecordingConnection() + let first = DaemonConnectionChannel( + connection: transport, mode: .v2(version: 2), replayStore: DaemonReplayStore()) + let second = DaemonConnectionChannel( + connection: transport, mode: .v2(version: 2), replayStore: DaemonReplayStore()) + + await withTaskGroup(of: Void.self) { group in + for index in 0..<16 { + group.addTask { + let channel = index.isMultiple(of: 2) ? first : second + try? await channel.sendError( + code: .transportFailure, message: "error-\(index)") + } + } + } + + let maximum = await transport.maxConcurrentSends + XCTAssertEqual(maximum, 1) + XCTAssertEqual(transport.frames.count, 16) + } + + func testRemoteBridgeStateValidatesSecurityFields() throws { + let issued = Date(timeIntervalSince1970: 1_700_000_000) + let state = RemoteBridgeState( + instanceID: UUID(), + generation: 3, + remotePort: 42_345, + capability: String(repeating: "a", count: 64), + issuedAt: issued, + expiresAt: issued.addingTimeInterval(3_600)) + + XCTAssertEqual(try state.validated(), state) + } + + func testRemoteBridgeStateRejectsShortCapability() { + let state = RemoteBridgeState( + instanceID: UUID(), + generation: 1, + remotePort: 42_345, + capability: "short", + issuedAt: .now, + expiresAt: .now.addingTimeInterval(60)) + + XCTAssertThrowsError(try state.validated()) + } + + func testProcessRequestPreservesWindowsArguments() { + let request = ProcessRequest( + executable: URL(fileURLWithPath: #"C:\Tools\agent.exe"#), + arguments: ["space value", #"quote"value"#, "雪"], + workingDirectory: URL(fileURLWithPath: #"C:\Projects\Demo"#), + environment: ["GRAPHCODE_TEST": "1"]) + + XCTAssertEqual(request.arguments[0], "space value") + XCTAssertEqual(request.environment["GRAPHCODE_TEST"], "1") + } +} diff --git a/investigation/spikes/swift-contracts/prepare.ps1 b/investigation/spikes/swift-contracts/prepare.ps1 new file mode 100644 index 00000000..f20e6b93 --- /dev/null +++ b/investigation/spikes/swift-contracts/prepare.ps1 @@ -0,0 +1,33 @@ +$ErrorActionPreference = "Stop" + +$spike = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = Resolve-Path (Join-Path $spike "..\..\..") +$targetRoot = Join-Path $spike "Sources\GraphcodeWindowsContracts" + +New-Item -ItemType Directory -Force -Path $targetRoot | Out-Null +$supportLink = Join-Path $targetRoot "SupportDirectory.swift" +$quickChatLink = Join-Path $targetRoot "QuickChatStore.swift" +foreach ($file in @($supportLink, $quickChatLink)) { + if (Test-Path -LiteralPath $file) { + Remove-Item -LiteralPath $file -Force + } +} +$links = @{ + Domain = Join-Path $repo "GraphcodeKit\Sources\Domain" + IPC = Join-Path $repo "GraphcodeKit\Sources\IPC" + Platform = Join-Path $repo "GraphcodeKit\Sources\Platform" +} +foreach ($entry in $links.GetEnumerator()) { + $link = Join-Path $targetRoot $entry.Key + if (Test-Path $link) { + Remove-Item $link -Force + } + New-Item -ItemType Junction -Path $link -Target $entry.Value | Out-Null +} +New-Item -ItemType HardLink ` + -Path $supportLink ` + -Target (Join-Path $repo "GraphcodeKit\Sources\SupportDirectory.swift") | Out-Null +New-Item -ItemType HardLink ` + -Path $quickChatLink ` + -Target (Join-Path $repo "GraphcodeKit\Sources\QuickChatStore.swift") | Out-Null +Write-Host "Linked Graphcode contract sources" diff --git a/investigation/spikes/swift-full/Package.resolved b/investigation/spikes/swift-full/Package.resolved new file mode 100644 index 00000000..02a8ccb0 --- /dev/null +++ b/investigation/spikes/swift-full/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "a819c5704c91f1dbb8eb3827835fa41130bb7b01c5bef1fb02b57f1e74b205d3", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/investigation/spikes/swift-full/Package.swift b/investigation/spikes/swift-full/Package.swift new file mode 100644 index 00000000..7910f47c --- /dev/null +++ b/investigation/spikes/swift-full/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeKitWindowsFullSpike", + products: [ + .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]) + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + .target( + name: "GraphcodeKit", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "Sources/GraphcodeKit", + exclude: [ + "CLI", + "IPC", + "Sessions", + "DaemonBootstrap.swift", + "GraphStore.swift", + "GraphcodeSettingsStore.swift", + "ProjectRegistry.swift", + "QuickChatStore.swift", + "TerminalLayoutStore.swift", + "Domain/BackendCommand.swift", + ], + sources: [ + "Domain", + "Platform", + "ProjectPersistence.swift", + "SupportDirectory.swift", + ]), + .testTarget( + name: "GraphcodeKitWindowsTests", + dependencies: ["GraphcodeKit"], + path: "Tests/GraphcodeKitWindowsTests"), + ]) diff --git a/investigation/spikes/swift-full/Tests/GraphcodeKitWindowsTests/PlatformTests.swift b/investigation/spikes/swift-full/Tests/GraphcodeKitWindowsTests/PlatformTests.swift new file mode 100644 index 00000000..53d96750 --- /dev/null +++ b/investigation/spikes/swift-full/Tests/GraphcodeKitWindowsTests/PlatformTests.swift @@ -0,0 +1,1078 @@ +import Foundation +import XCTest + +@testable import GraphcodeKit + +#if canImport(Darwin) + import Darwin +#endif +#if os(Windows) + import WinSDK +#endif + +final class PlatformTests: XCTestCase { + func testSupportDirectoryAcceptsWindowsAbsoluteOverride() { + let home = URL(fileURLWithPath: #"C:\Users\Test User"#, isDirectory: true) + let root = SupportDirectory.url( + environment: [SupportDirectory.environmentKey: #"D:\Graph Code\State"#], + homeDirectory: home) + + XCTAssertEqual(root.path, #"D:/Graph Code/State"#) + } + + func testSupportDirectoryResolvesRelativeOverrideAgainstHome() { + let home = URL(fileURLWithPath: #"C:\Users\Test User"#, isDirectory: true) + let root = SupportDirectory.url( + environment: [SupportDirectory.environmentKey: "graphcode-dev"], + homeDirectory: home) + + XCTAssertEqual( + root.path, + home.appendingPathComponent("graphcode-dev", isDirectory: true).path) + } + + func testSupportDirectoryResolvesWindowsEnvironmentKeyCaseInsensitively() throws { + #if os(Windows) + for key in ["graphcode_support_dir", "GrApHcOdE_sUpPoRt_DiR"] { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-support-override-\(UUID().uuidString)", isDirectory: true) + let home = directory.appendingPathComponent("home", isDirectory: true) + let destination = directory.appendingPathComponent("override", isDirectory: true) + let legacy = directory.appendingPathComponent("legacy", isDirectory: true) + try FileManager.default.createDirectory( + at: legacy, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = [key: destination.path] + XCTAssertEqual( + SupportDirectory.url(environment: environment, homeDirectory: home).path, + destination.path) + + SupportDirectory.prepare( + environment: environment, + homeDirectory: home, + legacy: legacy) + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: legacy.path)) + } + #else + let home = URL(fileURLWithPath: "/Users/test", isDirectory: true) + for key in ["graphcode_support_dir", "GrApHcOdE_sUpPoRt_DiR"] { + let ignored = SupportDirectory.url( + environment: [key: "graphcode-dev"], + homeDirectory: home) + XCTAssertEqual( + ignored.path, + home.appendingPathComponent(".graphcode", isDirectory: true).path) + } + #endif + } + + func testSupportDirectoryTreatsNewlineOnlyOverrideAsAbsentForMigration() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-support-newline-\(UUID().uuidString)", isDirectory: true) + let home = directory.appendingPathComponent("home", isDirectory: true) + let destination = home.appendingPathComponent(".graphcode", isDirectory: true) + let legacy = directory.appendingPathComponent("legacy", isDirectory: true) + try FileManager.default.createDirectory( + at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: legacy, withIntermediateDirectories: true) + try Data("legacy".utf8).write( + to: legacy.appendingPathComponent("marker", isDirectory: false)) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = [SupportDirectory.environmentKey: " \r\n\t "] + XCTAssertEqual( + SupportDirectory.url(environment: environment, homeDirectory: home).path, + destination.path) + + SupportDirectory.prepare( + environment: environment, + homeDirectory: home, + legacy: legacy) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: destination.appendingPathComponent("marker").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.path)) + } + + func testCanonicalProjectPathAcceptsDrivePathAndRejectsRoot() throws { + let paths = WindowsPlatformPaths( + homeDirectory: URL(fileURLWithPath: #"C:\Users\Test User"#, isDirectory: true)) + let canonical = try paths.canonicalProjectPath(#"C:\Projects\GraphCode Demo\.\src\.."#) + + XCTAssertTrue(canonical.contains(#"C:/Projects/GraphCode Demo"#)) + XCTAssertThrowsError(try paths.canonicalProjectPath(#"C:\"#)) + } + + func testCanonicalProjectPathRejectsCanonicalizedDriveShareAndRootRelativePaths() { + let paths = WindowsPlatformPaths() + for path in [ + #"C:\"#, + #"C:\Projects\.."#, + #"\\server\share"#, + #"\\server\share\folder\.."#, + #"\path"#, + #"/path"#, + #"C:relative"#, + ] { + XCTAssertThrowsError(try paths.canonicalProjectPath(path), path) + } + } + + func testWindowsCanonicalProjectPathRejectsExtendedUNCShareRoots() { + let paths = WindowsPlatformPaths() + for path in [ + #"\\?\UNC\server\share"#, + #"\\?\UNC\server\share\"#, + #"\\?\UNC\server\share\."#, + #"\\?\UNC\server\share\.\."#, + ] { + XCTAssertThrowsError(try paths.canonicalProjectPath(path), path) + } + } + + func testWindowsCanonicalProjectPathNormalizesExtendedUNCDeviceCase() throws { + #if os(Windows) + let paths = WindowsPlatformPaths() + for root in [ + #"\\?\unc\server\share"#, + #"\\?\uNc\server\share\"#, + #"\\?\UnC\server\share\."#, + ] { + XCTAssertThrowsError(try paths.canonicalProjectPath(root), root) + } + + for descendant in [ + #"\\?\unc\server\share\folder\..\project"#, + #"\\?\uNc\SERVER\Share\.\project"#, + ] { + let canonical = try paths.canonicalProjectPath(descendant) + let normalized = + canonical + .replacingOccurrences(of: "\\", with: "/") + .lowercased() + XCTAssertTrue( + normalized.hasSuffix("/server/share/project"), + canonical) + } + #else + throw XCTSkip("Windows extended UNC normalization assertion") + #endif + } + + func testWindowsCanonicalProjectPathRejectsJunctionToDriveRoot() async throws { + #if os(Windows) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-windows-root-junction-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let junction = directory.appendingPathComponent("root-junction", isDirectory: true) + let comSpec = + ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe" + let command = "mklink /J \"\(junction.path)\" \"C:\\\"" + let result = try await FoundationProcessRunner().run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", command])) + guard result.exitCode == 0 else { + XCTFail( + "mklink failed: stdout=\(String(decoding: result.standardOutput, as: UTF8.self)) " + + "stderr=\(String(decoding: result.standardError, as: UTF8.self))") + return + } + + XCTAssertThrowsError( + try WindowsPlatformPaths().canonicalProjectPath(junction.path) + ) { error in + XCTAssertEqual(error as? PlatformPathError, .rootPath(junction.path)) + } + #else + throw XCTSkip("Windows reparse-point root assertion") + #endif + } + + func testDarwinCanonicalProjectPathRejectsSymlinkToFilesystemRoot() throws { + #if canImport(Darwin) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-darwin-root-link-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let link = directory.appendingPathComponent("root-link", isDirectory: true) + try FileManager.default.createSymbolicLink( + atPath: link.path, + withDestinationPath: "/") + + XCTAssertThrowsError(try DarwinPlatformPaths().canonicalProjectPath(link.path)) { error in + XCTAssertEqual(error as? PlatformPathError, .rootPath(link.path)) + } + #else + throw XCTSkip("Darwin symlink-root assertion") + #endif + } + + func testPersistenceKeyIsSafeStableAndDistinct() { + let paths = WindowsPlatformPaths() + let first = paths.persistenceKey(forProjectPath: #"C:\Projects\GraphCode Demo"#) + let equivalent = paths.persistenceKey(forProjectPath: #"C:\Projects\GraphCode Demo\."#) + let second = paths.persistenceKey(forProjectPath: #"C:\Projects\Other"#) + + XCTAssertEqual(first, equivalent) + XCTAssertNotEqual(first, second) + XCTAssertNotNil(first.range(of: #"^v1-[0-9a-f]{64}$"#, options: .regularExpression)) + XCTAssertLessThanOrEqual(first.utf8.count, 80) + } + + func testWindowsShellClassifiesScriptExtensions() throws { + let strategy = WindowsShellStrategy( + commandPrompt: URL(fileURLWithPath: #"C:\Windows\System32\cmd.exe"#), + powerShell: URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#)) + let script = URL(fileURLWithPath: #"C:\Tools\echo args.cmd"#) + let invocation = try strategy.invocation( + executable: script, + arguments: ["space value", #"quote"value"#], + workingDirectory: nil, + environment: [:]) + + XCTAssertEqual(invocation.kind, .commandPrompt) + XCTAssertEqual(invocation.request.arguments.count, 5) + XCTAssertEqual(Array(invocation.request.arguments.prefix(4)), ["/d", "/q", "/s", "/c"]) + let command = invocation.request.arguments[4] + XCTAssertNil(invocation.request.standardInput) + XCTAssertTrue(command.contains(script.path)) + XCTAssertTrue(command.contains("space value")) + XCTAssertTrue(command.contains(#"quote^"value"#)) + } + + func testWindowsShellFallsBackToSystemPowerShell() { + let systemRoot = + ProcessInfo.processInfo.environment["SystemRoot"] + ?? ProcessInfo.processInfo.environment["WINDIR"] + ?? #"C:\Windows"# + let strategy = WindowsShellStrategy( + environment: [ + "ProgramW6432": #"C:\GraphCode\missing-program-files"#, + "PATH": #"C:\GraphCode\missing-bin"#, + "SystemRoot": systemRoot, + ]) + + let normalizedPath = strategy.powerShell.path.replacingOccurrences(of: "/", with: "\\") + XCTAssertTrue( + normalizedPath.localizedCaseInsensitiveContains( + #"WindowsPowerShell\v1.0\powershell.exe"#)) + } + + func testWindowsShellEscapesHostileCmdArguments() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-hostile-cmd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let script = directory.appendingPathComponent("echo-hostile.cmd") + try "@echo off\r\necho ARG:%~1\r\n".write(to: script, atomically: true, encoding: .utf8) + let strategy = WindowsShellStrategy( + commandPrompt: URL( + fileURLWithPath: ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe")) + for argument in ["a&b", "a|b", "a", "a(e)", "a^b", "a!b", "a%b"] { + let invocation = try strategy.invocation( + executable: script, + arguments: [argument], + workingDirectory: directory, + environment: [:]) + + let result = try await FoundationProcessRunner().run(invocation.request) + let output = String(decoding: result.standardOutput, as: UTF8.self) + XCTAssertEqual( + result.exitCode, + 0, + "\(argument): stdout=\(output) " + + "stderr=\(String(decoding: result.standardError, as: UTF8.self))") + XCTAssertEqual( + output, + "ARG:\(argument)\r\n", + "\(argument): args=\(invocation.request.arguments) stdout=\(output)") + } + } + + func testWindowsShellRejectsLineBreakInjection() { + let strategy = WindowsShellStrategy() + let script = URL(fileURLWithPath: #"C:\Tools\echo.cmd"#) + + XCTAssertThrowsError( + try strategy.invocation( + executable: script, + arguments: ["safe\r\necho injected"], + workingDirectory: nil, + environment: [:]) + ) { error in + XCTAssertEqual(error as? ShellStrategyError, .commandContainsLineBreak) + } + XCTAssertThrowsError( + try strategy.invocation( + executable: URL(fileURLWithPath: "C:\\Tools\\echo\r\n.cmd"), + arguments: [], + workingDirectory: nil, + environment: [:]) + ) { error in + XCTAssertEqual(error as? ShellStrategyError, .commandContainsLineBreak) + } + } + + func testProcessRunnerCapturesCwdEnvironmentAndOutput() async throws { + let comSpec = + ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe" + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-platform-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let runner = FoundationProcessRunner() + let result = try await runner.run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", "echo %GRAPHCODE_PLATFORM_TEST% & cd"], + workingDirectory: directory, + environment: ["GRAPHCODE_PLATFORM_TEST": "platform-ok"])) + + XCTAssertEqual(result.exitCode, 0) + let output = String(decoding: result.standardOutput, as: UTF8.self) + XCTAssertTrue(output.contains("platform-ok")) + XCTAssertTrue(output.localizedCaseInsensitiveContains(directory.lastPathComponent)) + } + + func testProcessRunnerPreservesWindowsArgvZeroForDirectExecutable() async throws { + #if os(Windows) + let powerShell = URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#) + let result = try await FoundationProcessRunner().run( + ProcessRequest( + executable: powerShell, + arguments: [ + "-NoLogo", + "-NoProfile", + "-Command", + "[Environment]::GetCommandLineArgs()[0]", + ])) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + String(decoding: result.standardOutput, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "/", with: "\\"), + powerShell.path.replacingOccurrences(of: "/", with: "\\")) + #else + throw XCTSkip("Windows argv[0] assertion") + #endif + } + + func testProcessRunnerExecutesCmdAndPowerShellScripts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-scripts-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let commandScript = directory.appendingPathComponent("echo args.cmd") + try "@echo off\r\necho CMD_OK:%~1\r\n".write( + to: commandScript, + atomically: true, + encoding: .utf8) + let powerShellScript = directory.appendingPathComponent("echo args.ps1") + try #"param([string]$Value) Write-Output "PS_OK:$Value""#.write( + to: powerShellScript, + atomically: true, + encoding: .utf8) + + let strategy = WindowsShellStrategy( + commandPrompt: URL( + fileURLWithPath: ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe")) + let runner = FoundationProcessRunner() + + let command = try strategy.invocation( + executable: commandScript, + arguments: ["space value"], + workingDirectory: directory, + environment: [:]) + let commandResult: ProcessResult + do { + commandResult = try await runner.run(command.request) + } catch { + XCTFail("cmd launch failed: \(error)") + return + } + XCTAssertEqual(commandResult.exitCode, 0) + XCTAssertEqual( + commandResult.standardOutput, + Data("CMD_OK:space value\r\n".utf8)) + XCTAssertEqual(commandResult.standardError, Data()) + + let powerShell = try strategy.invocation( + executable: powerShellScript, + arguments: ["-Value", "space value"], + workingDirectory: directory, + environment: [:]) + let powerShellResult: ProcessResult + do { + powerShellResult = try await runner.run(powerShell.request) + } catch { + XCTFail("PowerShell launch failed: \(error)") + return + } + XCTAssertEqual(powerShellResult.exitCode, 0) + XCTAssertTrue( + String(decoding: powerShellResult.standardOutput, as: UTF8.self) + .contains("PS_OK:space value")) + } + + func testProcessRunnerTimesOutAndCancels() async { + let powerShell = URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#) + let runner = FoundationProcessRunner() + + do { + _ = try await runner.run( + ProcessRequest( + executable: powerShell, + arguments: ["-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 5"]), + timeout: .milliseconds(50)) + XCTFail("Expected timeout") + } catch let error as ProcessRunnerError { + XCTAssertEqual(error, .timedOut) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testProcessRunnerCancellationIsExplicit() async throws { + let powerShell = URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#) + let runner = FoundationProcessRunner() + let task = Task { + try await runner.run( + ProcessRequest( + executable: powerShell, + arguments: ["-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 5"])) + } + + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch let error as ProcessRunnerError { + XCTAssertEqual(error, .cancelled) + } + } + + func testProcessRunnerCancellationBeforeLaunchResumesAndDoesNotLaunch() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-cancel-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let marker = directory.appendingPathComponent("launched.txt") + let gate = LaunchGate() + let runner = FoundationProcessRunner(beforeStart: { await gate.wait() }) + let comSpec = + ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe" + + let task = Task { + try await runner.run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", "echo launched > \"\(marker.path)\""])) + } + await gate.waitUntilEntered() + task.cancel() + await gate.release() + + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch let error as ProcessRunnerError { + XCTAssertEqual(error, .cancelled) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + + func testProcessRunnerTimeoutBeforeLaunchResumesAndDoesNotLaunch() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-timeout-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let marker = directory.appendingPathComponent("launched.txt") + let gate = LaunchGate() + let runner = FoundationProcessRunner(beforeStart: { await gate.wait() }) + let comSpec = + ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe" + + let task = Task { + try await runner.run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", "echo launched > \"\(marker.path)\""]), + timeout: .milliseconds(1)) + } + await gate.waitUntilEntered() + try await Task.sleep(for: .milliseconds(100)) + await gate.release() + + do { + _ = try await task.value + XCTFail("Expected timeout") + } catch let error as ProcessRunnerError { + XCTAssertEqual(error, .timedOut) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + + func testProcessRunnerTimeoutKillsChildProcessAndDrainsPipes() async throws { + try await assertProcessTreeTermination(.timeout) + } + + func testProcessRunnerCancellationKillsChildProcessAndDrainsPipes() async throws { + try await assertProcessTreeTermination(.cancellation) + } + + func testWindowsSuccessfulRootExitKillsBackgroundDescendantAndDrainsPipes() async throws { + #if os(Windows) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-windows-success-tree-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let pidFile = directory.appendingPathComponent("child.pid") + let escapedPIDFile = pidFile.path.replacingOccurrences(of: "'", with: "''") + let powerShell = URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#) + let script = + #"$startInfo = New-Object System.Diagnostics.ProcessStartInfo; $startInfo.FileName = $env:ComSpec; $startInfo.Arguments = '/d /c echo child-output & ping.exe -t 127.0.0.1'; $startInfo.UseShellExecute = $false; $child = [System.Diagnostics.Process]::Start($startInfo); Set-Content -LiteralPath '$PID_FILE' -Value $child.Id -NoNewline; Start-Sleep -Milliseconds 200; exit 0"# + .replacingOccurrences(of: "$PID_FILE", with: escapedPIDFile) + let completion = ProcessCompletionBox() + let task = Task { + do { + let result = try await FoundationProcessRunner().run( + ProcessRequest( + executable: powerShell, + arguments: ["-NoLogo", "-NoProfile", "-Command", script])) + await completion.finish(.succeeded(result)) + } catch let error as ProcessRunnerError { + await completion.finish(.failed(error)) + } catch { + await completion.finish(.unexpected(String(describing: error))) + } + } + + var childPID: DWORD? + for _ in 0..<200 { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8), + let parsed = UInt32(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = DWORD(parsed) + break + } + try await Task.sleep(for: .milliseconds(10)) + } + guard let childPID else { + XCTFail("The successful Windows child did not publish its PID") + task.cancel() + _ = await task.value + return + } + + var state: ProcessCompletionState? + for _ in 0..<200 { + state = await completion.current() + if state != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNotNil( + state, + "A successful root exit must terminate its Job Object before draining pipes") + if state == nil { + task.cancel() + } + _ = await task.value + + let finalState = await completion.current() + guard case .succeeded(let result) = finalState else { + XCTFail("Expected successful root completion, got \(String(describing: finalState))") + return + } + XCTAssertEqual(result.exitCode, 0) + XCTAssertTrue( + String(decoding: result.standardOutput, as: UTF8.self) + .contains("child-output"), + "The descendant must inherit and drain the root stdout pipe") + + var childExited = false + for _ in 0..<100 { + let handle = OpenProcess( + DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, childPID) + if let handle { + _ = CloseHandle(handle) + try await Task.sleep(for: .milliseconds(25)) + } else { + childExited = true + break + } + } + XCTAssertTrue(childExited) + #else + throw XCTSkip("Windows successful-root process-tree assertion") + #endif + } + + func testConcurrentWindowsLaunchesDoNotCrossInheritPipeHandles() async throws { + #if os(Windows) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-handle-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let comSpec = + ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? "cmd.exe" + + for iteration in 0..<16 { + let iterationDirectory = directory.appendingPathComponent( + "iteration-\(iteration)", isDirectory: true) + try FileManager.default.createDirectory( + at: iterationDirectory, withIntermediateDirectories: true) + let longMarker = iterationDirectory.appendingPathComponent("long.started") + let shortMarker = iterationDirectory.appendingPathComponent("short.started") + let longScript = + "echo started>\"\(longMarker.path)\" & ping -n 30 127.0.0.1 > nul" + let shortScript = + "echo started>\"\(shortMarker.path)\" & ping -n 30 127.0.0.1 > nul" + let longCompletion = ProcessCompletionBox() + let shortCompletion = ProcessCompletionBox() + + let barrier = LaunchBarrier(count: 2) + let synchronizedRunner = FoundationProcessRunner(beforeStart: { + await barrier.wait() + }) + let longTask = Task { + do { + let result = try await synchronizedRunner.run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", longScript])) + await longCompletion.finish(.succeeded(result)) + } catch let error as ProcessRunnerError { + await longCompletion.finish(.failed(error)) + } catch { + await longCompletion.finish(.unexpected(String(describing: error))) + } + } + let shortTask = Task { + do { + let result = try await synchronizedRunner.run( + ProcessRequest( + executable: URL(fileURLWithPath: comSpec), + arguments: ["/d", "/c", shortScript])) + await shortCompletion.finish(.succeeded(result)) + } catch let error as ProcessRunnerError { + await shortCompletion.finish(.failed(error)) + } catch { + await shortCompletion.finish(.unexpected(String(describing: error))) + } + } + + var markersPublished = false + for _ in 0..<200 { + if FileManager.default.fileExists(atPath: longMarker.path), + FileManager.default.fileExists(atPath: shortMarker.path) + { + markersPublished = true + break + } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertTrue(markersPublished, "Both concurrent processes must start") + + shortTask.cancel() + var shortState: ProcessCompletionState? + for _ in 0..<200 { + shortState = await shortCompletion.current() + if shortState != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(shortState, .failed(.cancelled)) + let longState = await longCompletion.current() + XCTAssertNil(longState) + let shortFinalState = await shortCompletion.current() + XCTAssertEqual( + shortFinalState, + .failed(.cancelled), + "The cancelled process must finish its pipe readers while its sibling remains alive") + + longTask.cancel() + _ = await shortTask.value + _ = await longTask.value + } + #else + throw XCTSkip("Windows handle-inheritance assertion") + #endif + } + + private enum TreeTermination: Equatable { + case timeout + case cancellation + } + + private func assertProcessTreeTermination(_ termination: TreeTermination) async throws { + #if os(Windows) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-tree-timeout-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let pidFile = directory.appendingPathComponent("child.pid") + let powerShell = URL( + fileURLWithPath: #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#) + let script = + #"$child = Start-Process -FilePath $env:ComSpec -ArgumentList '/d','/c','ping.exe -t 127.0.0.1 > nul' -PassThru; Set-Content -LiteralPath '$PID_FILE' -Value $child.Id -NoNewline; Start-Sleep -Seconds 30"# + .replacingOccurrences( + of: "$PID_FILE", with: pidFile.path.replacingOccurrences(of: "'", with: "''")) + let task = Task { + try await FoundationProcessRunner().run( + ProcessRequest( + executable: powerShell, + arguments: ["-NoLogo", "-NoProfile", "-Command", script]), + timeout: termination == .timeout ? .seconds(15) : nil) + } + + var childPID: DWORD? + for _ in 0..<400 { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8), + let parsed = UInt32(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = DWORD(parsed) + break + } + try await Task.sleep(for: .milliseconds(25)) + } + guard let childPID else { + XCTFail("The child process did not publish its PID") + _ = try? await task.value + return + } + + if termination == .cancellation { + task.cancel() + } + do { + _ = try await task.value + XCTFail("Expected \(termination)") + } catch let error as ProcessRunnerError { + XCTAssertEqual( + error, + termination == .timeout ? .timedOut : .cancelled) + } + + var childExited = false + for _ in 0..<40 { + let handle = OpenProcess( + DWORD(PROCESS_QUERY_LIMITED_INFORMATION), false, childPID) + if let handle { + _ = CloseHandle(handle) + try await Task.sleep(for: .milliseconds(25)) + } else { + childExited = true + break + } + } + XCTAssertTrue(childExited) + #else + throw XCTSkip("Windows process-tree assertion") + #endif + } + + func testDarwinProcessGroupKillsDescendants() async throws { + #if canImport(Darwin) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-darwin-tree-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let pidFile = directory.appendingPathComponent("child.pid") + let escapedPIDFile = pidFile.path.replacingOccurrences(of: "'", with: "'\\''") + let script = "sleep 30 & child=$!; printf '%s' \"$child\" > '\(escapedPIDFile)'; wait" + let task = Task { + try await FoundationProcessRunner().run( + ProcessRequest( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script]), + timeout: .seconds(2)) + } + + var childPID: pid_t? + for _ in 0..<200 { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8), + let parsed = pid_t(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsed + break + } + try await Task.sleep(for: .milliseconds(25)) + } + guard let childPID else { + XCTFail("The Darwin child process did not publish its PID") + _ = try? await task.value + return + } + + do { + _ = try await task.value + XCTFail("Expected timeout") + } catch let error as ProcessRunnerError { + XCTAssertEqual(error, .timedOut) + } + + var childExited = false + for _ in 0..<100 { + if kill(childPID, 0) == -1 { + childExited = true + break + } + try await Task.sleep(for: .milliseconds(25)) + } + XCTAssertTrue(childExited) + #else + throw XCTSkip("Darwin process-group assertion") + #endif + } + + func testDarwinProcessGroupKillsBackgroundDescendantAfterSuccessfulRootExit() async throws { + #if canImport(Darwin) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-darwin-success-tree-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let pidFile = directory.appendingPathComponent("child.pid") + let escapedPIDFile = pidFile.path.replacingOccurrences(of: "'", with: "'\\''") + let script = + "sleep 30 & child=$!; printf '%s' \"$child\" > '\(escapedPIDFile)'; exit 0" + let completion = ProcessCompletionBox() + let task = Task { + do { + let result = try await FoundationProcessRunner().run( + ProcessRequest( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script])) + await completion.finish(.succeeded(result)) + } catch let error as ProcessRunnerError { + await completion.finish(.failed(error)) + } catch { + await completion.finish(.unexpected(String(describing: error))) + } + } + + var childPID: pid_t? + for _ in 0..<200 { + if let contents = try? String(contentsOf: pidFile, encoding: .utf8), + let parsed = pid_t(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsed + break + } + try await Task.sleep(for: .milliseconds(10)) + } + guard let childPID else { + XCTFail("The successful Darwin child did not publish its PID") + _ = await task.value + return + } + + var state: ProcessCompletionState? + for _ in 0..<200 { + state = await completion.current() + if state != nil { break } + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertNotNil( + state, + "A successful root exit must release after cleaning up its process group") + if state == nil { + _ = kill(childPID, SIGKILL) + } + _ = await task.value + + let finalState = await completion.current() + guard case .succeeded(let result) = finalState else { + XCTFail("Expected successful root completion, got \(String(describing: finalState))") + return + } + XCTAssertEqual(result.exitCode, 0) + + var childExited = false + for _ in 0..<100 { + if kill(childPID, 0) == -1 { + childExited = true + break + } + try await Task.sleep(for: .milliseconds(25)) + } + XCTAssertTrue(childExited) + #else + throw XCTSkip("Darwin successful-root process-group assertion") + #endif + } + + func testProjectPersistenceUsesSafeWindowsKey() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-persistence-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let persistence = ProjectPersistence( + baseDirectory: directory, + platformPaths: WindowsPlatformPaths()) + let project = ProjectRef(path: #"C:\Projects\GraphCode Demo"#, name: "Demo") + let graph = LoopGraph(project: project, nodes: [LoopNode(title: "Worker")]) + + persistence.saveGraph(graph) + + let files = try FileManager.default.contentsOfDirectory( + at: directory.appendingPathComponent("projects", isDirectory: true), + includingPropertiesForKeys: nil) + XCTAssertEqual(files.count, 1) + XCTAssertNotNil( + files.first?.lastPathComponent.range( + of: #"^v1-[0-9a-f]{64}\.json$"#, + options: .regularExpression)) + XCTAssertEqual(persistence.loadGraph(path: project.path)?.project.path, project.path) + } + + func testProjectPersistenceMigratesAndDeletesLegacyPathFile() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-legacy-persistence-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let persistence = ProjectPersistence( + baseDirectory: directory, + platformPaths: DarwinPlatformPaths()) + let project = ProjectRef(path: "/tmp/legacy-windows-test", name: "Legacy") + let graph = LoopGraph(project: project, nodes: [LoopNode(title: "Legacy")]) + let legacyURL = + directory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("_tmp_legacy-windows-test.json") + try JSONEncoder() + .encode(graph) + .write(to: legacyURL) + + XCTAssertEqual(persistence.loadGraph(path: project.path)?.nodes.first?.title, "Legacy") + XCTAssertFalse(FileManager.default.fileExists(atPath: legacyURL.path)) + + persistence.deleteGraph(path: project.path) + XCTAssertNil(persistence.loadGraph(path: project.path)) + } + + func testProjectPersistenceLeavesCollidingLegacyFileForAnotherGraph() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-legacy-collision-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let persistence = ProjectPersistence( + baseDirectory: directory, + platformPaths: DarwinPlatformPaths()) + let requestedPath = "/tmp/a/b_c" + let otherPath = "/tmp/a_b/c" + let legacyURL = + directory + .appendingPathComponent("projects", isDirectory: true) + .appendingPathComponent("_tmp_a_b_c.json") + let otherGraph = LoopGraph(project: ProjectRef(path: otherPath, name: "Other")) + try JSONEncoder().encode(otherGraph).write(to: legacyURL) + + XCTAssertNil(persistence.loadGraph(path: requestedPath)) + persistence.deleteGraph(path: requestedPath) + XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path)) + } +} +private actor LaunchGate { + private var entered = false + private var entryContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + + func wait() async { + entered = true + entryContinuation?.resume() + entryContinuation = nil + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilEntered() async { + if entered { return } + await withCheckedContinuation { continuation in + entryContinuation = continuation + } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor LaunchBarrier { + private let expected: Int + private var arrivals = 0 + private var continuations: [CheckedContinuation] = [] + + init(count: Int) { + expected = count + } + + func wait() async { + arrivals += 1 + guard arrivals < expected else { + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + return + } + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } +} + +private enum ProcessCompletionState: Equatable, Sendable { + case succeeded(ProcessResult) + case failed(ProcessRunnerError) + case unexpected(String) +} + +private actor ProcessCompletionBox { + private var state: ProcessCompletionState? + + func finish(_ state: ProcessCompletionState) { + self.state = state + } + + func current() -> ProcessCompletionState? { + state + } +} diff --git a/investigation/spikes/swift-full/prepare.ps1 b/investigation/spikes/swift-full/prepare.ps1 new file mode 100644 index 00000000..def11d9f --- /dev/null +++ b/investigation/spikes/swift-full/prepare.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" + +$spike = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = Resolve-Path (Join-Path $spike "..\..\..") +$sources = Join-Path $spike "Sources" +$link = Join-Path $sources "GraphcodeKit" +$target = Join-Path $repo "GraphcodeKit\Sources" + +New-Item -ItemType Directory -Force -Path $sources | Out-Null +if (Test-Path $link) { + Remove-Item $link +} +New-Item -ItemType Junction -Path $link -Target $target | Out-Null +Write-Host "Linked $link -> $target" diff --git a/investigation/spikes/swift-named-pipe/Package.swift b/investigation/spikes/swift-named-pipe/Package.swift new file mode 100644 index 00000000..ae66c174 --- /dev/null +++ b/investigation/spikes/swift-named-pipe/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeSwiftNamedPipeSpike", + targets: [ + .executableTarget(name: "GraphcodeSwiftNamedPipeSpike") + ]) diff --git a/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift b/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift new file mode 100644 index 00000000..e3781b16 --- /dev/null +++ b/investigation/spikes/swift-named-pipe/Sources/GraphcodeSwiftNamedPipeSpike/main.swift @@ -0,0 +1,299 @@ +import Foundation +import WinSDK + +enum PipeError: Error { + case win32(String, DWORD) + case invalidFrame + case frameTooLarge(UInt32) +} +let maxFrameBytes: UInt32 = 1_048_576 +func withWideString( + _ value: String, + _ body: (UnsafePointer) throws -> Result +) rethrows -> Result { + var buffer = Array(value.utf16) + buffer.append(0) + return try buffer.withUnsafeBufferPointer { pointer in + try body(pointer.baseAddress!) + } +} +func checkHandle(_ handle: HANDLE?, operation: String) throws -> HANDLE { + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw PipeError.win32(operation, GetLastError()) + } + return handle +} +func writeAll(_ bytes: [UInt8], to handle: HANDLE) throws { + var offset = 0 + while offset < bytes.count { + var written: DWORD = 0 + let succeeded = bytes.withUnsafeBytes { rawBuffer in + WriteFile( + handle, + rawBuffer.baseAddress!.advanced(by: offset), + DWORD(bytes.count - offset), + &written, + nil) + } + guard succeeded != false else { + throw PipeError.win32("WriteFile", GetLastError()) + } + offset += Int(written) + } +} +func readExactly(_ count: Int, from handle: HANDLE) throws -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: count) + var offset = 0 + while offset < count { + var bytesRead: DWORD = 0 + let succeeded = bytes.withUnsafeMutableBytes { rawBuffer in + ReadFile( + handle, + rawBuffer.baseAddress!.advanced(by: offset), + DWORD(count - offset), + &bytesRead, + nil) + } + guard succeeded != false else { + throw PipeError.win32("ReadFile", GetLastError()) + } + guard bytesRead > 0 else { throw PipeError.invalidFrame } + offset += Int(bytesRead) + } + return bytes +} +func writeFrame(_ text: String, to handle: HANDLE) throws { + let payload = Array(text.utf8) + let count = UInt32(payload.count) + let header: [UInt8] = [ + UInt8((count >> 24) & 0xff), + UInt8((count >> 16) & 0xff), + UInt8((count >> 8) & 0xff), + UInt8(count & 0xff), + ] + try writeAll(header + payload, to: handle) +} +func validatedFrameLength(_ header: [UInt8]) throws -> Int { + guard header.count == 4 else { throw PipeError.invalidFrame } + let count = + (UInt32(header[0]) << 24) + | (UInt32(header[1]) << 16) + | (UInt32(header[2]) << 8) + | UInt32(header[3]) + guard count <= maxFrameBytes else { throw PipeError.frameTooLarge(count) } + return Int(count) +} +func readFrame(from handle: HANDLE) throws -> String { + let count = try validatedFrameLength(readExactly(4, from: handle)) + let payload = try readExactly(count, from: handle) + guard let text = String(bytes: payload, encoding: .utf8) else { + throw PipeError.invalidFrame + } + return text +} +func createServerPipe( + named name: String, + maxInstances: DWORD = DWORD(bitPattern: PIPE_UNLIMITED_INSTANCES) +) + throws -> HANDLE +{ + try withWideString(name) { wideName in + try checkHandle( + CreateNamedPipeW( + wideName, + DWORD(PIPE_ACCESS_DUPLEX), + DWORD(PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT), + maxInstances, + 64 * 1024, + 64 * 1024, + 1_000, + nil), + operation: "CreateNamedPipeW") + } +} +func connectClient(to name: String) throws -> HANDLE { + try withWideString(name) { wideName in + guard WaitNamedPipeW(wideName, 2_000) != false else { + throw PipeError.win32("WaitNamedPipeW", GetLastError()) + } + return try checkHandle( + CreateFileW( + wideName, + DWORD(GENERIC_READ) | DWORD(bitPattern: GENERIC_WRITE), + 0, + nil, + DWORD(OPEN_EXISTING), + 0, + nil), + operation: "CreateFileW") + } +} +func serveOne( + pipeName: String, + ready: DispatchSemaphore, + result: @escaping @Sendable (Result) -> Void +) { + DispatchQueue.global().async { + do { + let pipe = try createServerPipe(named: pipeName) + defer { CloseHandle(pipe) } + ready.signal() + let connected = ConnectNamedPipe(pipe, nil) + guard connected != false || GetLastError() == ERROR_PIPE_CONNECTED else { + throw PipeError.win32("ConnectNamedPipe", GetLastError()) + } + let request = try readFrame(from: pipe) + try writeFrame("response:\(request)", to: pipe) + try writeFrame("event:graphChanged", to: pipe) + FlushFileBuffers(pipe) + DisconnectNamedPipe(pipe) + result(.success(request)) + } catch { + result(.failure(error)) + } + } +} +let pipeName = #"\\.\pipe\graphcode-spike-\#(GetCurrentProcessId())"# +let ready = DispatchSemaphore(value: 0) +let serversDone = DispatchGroup() +let clientsDone = DispatchGroup() +final class FailureStore: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + func append(_ text: String) { + lock.lock() + storage.append(text) + lock.unlock() + } + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } +} +final class SendableHandle: @unchecked Sendable { + let value: HANDLE + + init(_ value: HANDLE) { + self.value = value + } +} +let failures = FailureStore() +for _ in 0..<2 { + serversDone.enter() + serveOne(pipeName: pipeName, ready: ready) { result in + if case .failure(let error) = result { + failures.append("server: \(error)") + } + serversDone.leave() + } +} +ready.wait() +ready.wait() +for request in ["client-one", "client-two"] { + clientsDone.enter() + DispatchQueue.global().async { + defer { clientsDone.leave() } + do { + let client = try connectClient(to: pipeName) + defer { CloseHandle(client) } + try writeFrame(request, to: client) + let response = try readFrame(from: client) + let event = try readFrame(from: client) + guard response == "response:\(request)", event == "event:graphChanged" else { + throw PipeError.invalidFrame + } + } catch { + failures.append("client \(request): \(error)") + } + } +} +clientsDone.wait() +serversDone.wait() +let reconnectReady = DispatchSemaphore(value: 0) +let reconnectDone = DispatchSemaphore(value: 0) +serveOne(pipeName: pipeName, ready: reconnectReady) { result in + if case .failure(let error) = result { + failures.append("reconnect server: \(error)") + } + reconnectDone.signal() +} +reconnectReady.wait() +do { + let client = try connectClient(to: pipeName) + try writeFrame("reconnected", to: client) + guard try readFrame(from: client) == "response:reconnected", + try readFrame(from: client) == "event:graphChanged" + else { + throw PipeError.invalidFrame + } + CloseHandle(client) +} catch { + failures.append("reconnect client: \(error)") +} +reconnectDone.wait() +let missingName = pipeName + "-missing" +let missingError = withWideString(missingName) { wideName -> DWORD in + let handle = CreateFileW( + wideName, + DWORD(GENERIC_READ) | DWORD(bitPattern: GENERIC_WRITE), + 0, + nil, + DWORD(OPEN_EXISTING), + 0, + nil) + if handle != INVALID_HANDLE_VALUE { + CloseHandle(handle) + return DWORD(bitPattern: ERROR_SUCCESS) + } + return GetLastError() +} +if missingError != DWORD(bitPattern: ERROR_FILE_NOT_FOUND) { + failures.append("daemon unavailable error was \(missingError)") +} +let busyName = pipeName + "-busy" +do { + let busyPipe = try createServerPipe(named: busyName, maxInstances: 1) + let busyPipeBox = SendableHandle(busyPipe) + let connected = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = ConnectNamedPipe(busyPipeBox.value, nil) + connected.signal() + } + let firstClient = try connectClient(to: busyName) + connected.wait() + let timeoutError = withWideString(busyName) { wideName -> DWORD in + if WaitNamedPipeW(wideName, 100) != false { + return DWORD(bitPattern: ERROR_SUCCESS) + } + return GetLastError() + } + if timeoutError != DWORD(bitPattern: ERROR_SEM_TIMEOUT) { + failures.append("busy-pipe timeout error was \(timeoutError)") + } + CloseHandle(firstClient) + DisconnectNamedPipe(busyPipe) + CloseHandle(busyPipe) +} catch { + failures.append("timeout setup: \(error)") +} +do { + _ = try validatedFrameLength([0x7f, 0xff, 0xff, 0xff]) + failures.append("oversized frame was accepted") +} catch PipeError.frameTooLarge { +} catch { + failures.append("oversized frame returned unexpected error: \(error)") +} +if !failures.values.isEmpty { + for failure in failures.values { FileHandle.standardError.write(Data("\(failure)\n".utf8)) } + exit(1) +} +print("swift-named-pipe request-response: ok") +print("swift-named-pipe event-stream: ok") +print("swift-named-pipe multiple-clients: ok") +print("swift-named-pipe reconnect: ok") +print("swift-named-pipe daemon-unavailable: ok") +print("swift-named-pipe connection-availability-timeout: ok") +print("swift-named-pipe oversized-frame-rejection: ok") diff --git a/investigation/spikes/swift-paths/Package.swift b/investigation/spikes/swift-paths/Package.swift new file mode 100644 index 00000000..07e9ed45 --- /dev/null +++ b/investigation/spikes/swift-paths/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeWindowsPathSpike", + targets: [ + .executableTarget(name: "GraphcodeWindowsPathSpike") + ]) diff --git a/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift b/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift new file mode 100644 index 00000000..e639ec5d --- /dev/null +++ b/investigation/spikes/swift-paths/Sources/GraphcodeWindowsPathSpike/main.swift @@ -0,0 +1,26 @@ +import Foundation + +let projectPath = #"C:\Projects\GraphCode Demo"# +let supportOverride = #"D:\GraphCodeState"# +let home = FileManager.default.homeDirectoryForCurrentUser +let registryAcceptsPath = projectPath.hasPrefix("/") +let currentSupportResolution = + supportOverride.hasPrefix("/") + ? URL(fileURLWithPath: supportOverride, isDirectory: true) + : home.appendingPathComponent(supportOverride, isDirectory: true) +let persistenceFileName = + projectPath.replacingOccurrences(of: "/", with: "_") + ".json" +print("project=\(projectPath)") +print("registryAcceptsPath=\(registryAcceptsPath)") +print("supportOverrideResolved=\(currentSupportResolution.path)") +print("persistenceFileName=\(persistenceFileName)") +guard registryAcceptsPath == false else { + fatalError("Expected the current POSIX absolute-path check to reject a Windows drive path") +} +guard currentSupportResolution.path != supportOverride else { + fatalError("Expected the current support-directory logic to misclassify a Windows drive path") +} +guard persistenceFileName.contains("\\") && persistenceFileName.contains(":") else { + fatalError("Expected the current persistence filename sanitizer to retain Windows separators") +} +print("observed-current-windows-path-blockers") diff --git a/investigation/spikes/swift-portable/Package.resolved b/investigation/spikes/swift-portable/Package.resolved new file mode 100644 index 00000000..7170ff4c --- /dev/null +++ b/investigation/spikes/swift-portable/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "6dedbfa23d4a976c618db2b9f5e4d0b528c71af4076a35cf40632d21644b4754", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} \ No newline at end of file diff --git a/investigation/spikes/swift-portable/Package.swift b/investigation/spikes/swift-portable/Package.swift new file mode 100644 index 00000000..fbdf489e --- /dev/null +++ b/investigation/spikes/swift-portable/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodePortableDomainSpike", + products: [ + .library(name: "GraphcodePortableDomain", targets: ["GraphcodePortableDomain"]) + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + exact: "1.1.1") + ], + targets: [ + .target( + name: "GraphcodePortableDomain", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "Sources/GraphcodePortableDomain", + exclude: [ + "BackendCommand.swift", + "RemoteProjectLocation.swift", + "SessionBriefing.swift", + ]), + .testTarget( + name: "GraphcodePortableDomainTests", + dependencies: ["GraphcodePortableDomain"]), + ]) diff --git a/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift b/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift new file mode 100644 index 00000000..9a5bb04f --- /dev/null +++ b/investigation/spikes/swift-portable/Tests/GraphcodePortableDomainTests/PortableDomainTests.swift @@ -0,0 +1,28 @@ +import Foundation +import GraphcodePortableDomain +import XCTest + +final class PortableDomainTests: XCTestCase { + func testGraphRoundTripsWithAWindowsProjectPath() throws { + let graph = LoopGraph( + project: ProjectRef( + path: #"C:\Projects\GraphCode Demo"#, + name: "demo", + lastOpenedAt: Date(timeIntervalSince1970: 1_700_000_000))) + + let data = try JSONEncoder().encode(graph) + let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) + + XCTAssertEqual(decoded.id, graph.id) + XCTAssertEqual(decoded.nodes, graph.nodes) + XCTAssertEqual(decoded.edges, graph.edges) + XCTAssertEqual(decoded.project.path, #"C:\Projects\GraphCode Demo"#) + XCTAssertEqual(decoded.project.name, "demo") + } + + func testSettingsRoundTripUnchanged() throws { + let settings = GraphcodeSettings() + let data = try JSONEncoder().encode(settings) + XCTAssertEqual(try JSONDecoder().decode(GraphcodeSettings.self, from: data), settings) + } +} diff --git a/investigation/spikes/swift-portable/prepare.ps1 b/investigation/spikes/swift-portable/prepare.ps1 new file mode 100644 index 00000000..f610700f --- /dev/null +++ b/investigation/spikes/swift-portable/prepare.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" + +$spike = Split-Path -Parent $MyInvocation.MyCommand.Path +$repo = Resolve-Path (Join-Path $spike "..\..\..") +$sources = Join-Path $spike "Sources" +$link = Join-Path $sources "GraphcodePortableDomain" +$target = Join-Path $repo "GraphcodeKit\Sources\Domain" + +New-Item -ItemType Directory -Force -Path $sources | Out-Null +if (Test-Path $link) { + Remove-Item $link +} +New-Item -ItemType Junction -Path $link -Target $target | Out-Null +Write-Host "Linked $link -> $target" diff --git a/investigation/spikes/swift-process/Package.swift b/investigation/spikes/swift-process/Package.swift new file mode 100644 index 00000000..1d0f5821 --- /dev/null +++ b/investigation/spikes/swift-process/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "GraphcodeSwiftProcessSpike", + targets: [ + .executableTarget(name: "GraphcodeSwiftProcessSpike") + ]) diff --git a/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift b/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift new file mode 100644 index 00000000..4b7d3c6d --- /dev/null +++ b/investigation/spikes/swift-process/Sources/GraphcodeSwiftProcessSpike/main.swift @@ -0,0 +1,118 @@ +import Foundation + +struct ChildReport: Codable, Equatable { + var arguments: [String] + var workingDirectory: String + var environmentValue: String? +} +if CommandLine.arguments.dropFirst().first == "--child" { + let report = ChildReport( + arguments: Array(CommandLine.arguments.dropFirst(2)), + workingDirectory: FileManager.default.currentDirectoryPath, + environmentValue: ProcessInfo.processInfo.environment["GRAPHCODE_PROCESS_SPIKE"]) + FileHandle.standardOutput.write(try JSONEncoder().encode(report)) + exit(0) +} +struct ProcessResult { + var status: Int32 + var output: String +} +func run( + _ executable: String, + _ arguments: [String], + workingDirectory: URL? = nil, + environment: [String: String]? = nil +) throws -> ProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = workingDirectory + process.environment = environment + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return ProcessResult( + status: process.terminationStatus, + output: String(decoding: data, as: UTF8.self)) +} +let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode process spike \(UUID().uuidString)", isDirectory: true) +try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: temporaryDirectory) } +let currentExecutable = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL.path +var environment = ProcessInfo.processInfo.environment +environment["GRAPHCODE_PROCESS_SPIKE"] = "inherited" +let directArguments = ["space value", #"quote"value"#, "雪"] +let direct = try run( + currentExecutable, + ["--child"] + directArguments, + workingDirectory: temporaryDirectory, + environment: environment) +guard direct.status == 0, + let report = try? JSONDecoder().decode(ChildReport.self, from: Data(direct.output.utf8)) +else { + fatalError("Direct executable launch did not preserve argv, cwd, and environment") +} +FileHandle.standardOutput.write( + Data( + [ + "direct.arguments=\(report.arguments)", + "direct.cwd=\(report.workingDirectory)", + "direct.environment=\(report.environmentValue ?? "nil")", + "", + ].joined(separator: "\n").utf8)) +guard report.arguments == directArguments, + URL(fileURLWithPath: report.workingDirectory).standardizedFileURL + == temporaryDirectory.standardizedFileURL, + report.environmentValue == "inherited" +else { + fatalError("Direct executable launch changed argv, cwd, or environment") +} +let commandScript = temporaryDirectory.appendingPathComponent("echo args.cmd") +try "@echo off\r\necho CMD_OK:%~1\r\n".write( + to: commandScript, + atomically: true, + encoding: .utf8) +var commandScriptDirectlyLaunches = false +do { + let result = try run(commandScript.path, ["space value"]) + commandScriptDirectlyLaunches = + result.status == 0 && result.output.contains("CMD_OK:space value") +} catch {} +let commandHost = #"C:\Windows\System32\cmd.exe"# +let hostedCommand = try run( + commandHost, + ["/d", "/c", "call", commandScript.path, "space value"]) +FileHandle.standardOutput.write( + Data("cmd.status=\(hostedCommand.status) cmd.output=\(hostedCommand.output)\n".utf8)) +guard hostedCommand.status == 0, hostedCommand.output.contains("CMD_OK:space value") else { + fatalError("cmd.exe did not launch a .cmd shim with a spaced argument") +} +let powerShellScript = temporaryDirectory.appendingPathComponent("echo args.ps1") +try #"param([string]$Value) Write-Output "PS_OK:$Value""#.write( + to: powerShellScript, + atomically: true, + encoding: .utf8) +let powerShell = #"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"# +var powerShellScriptDirectlyLaunches = false +do { + let result = try run(powerShellScript.path, ["-Value", "space value"]) + powerShellScriptDirectlyLaunches = + result.status == 0 && result.output.contains("PS_OK:space value") +} catch {} +let hostedPowerShell = try run( + powerShell, + ["-NoLogo", "-NoProfile", "-File", powerShellScript.path, "-Value", "space value"]) +guard hostedPowerShell.status == 0, hostedPowerShell.output.contains("PS_OK:space value") else { + fatalError("PowerShell did not launch a .ps1 shim with a spaced argument") +} +print("swift-process direct-exe-argv-cwd-environment: ok") +print("swift-process direct-cmd-launches=\(commandScriptDirectlyLaunches)") +print("swift-process direct-ps1-launches=\(powerShellScriptDirectlyLaunches)") +print("swift-process cmd-hosted-shim: ok") +print("swift-process powershell-hosted-shim: ok") diff --git a/investigation/spikes/windows-terminal-gate/README.md b/investigation/spikes/windows-terminal-gate/README.md new file mode 100644 index 00000000..7d183f81 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/README.md @@ -0,0 +1,83 @@ +# GraphCode Windows terminal gate + +This spike is the smallest GraphCode-owned native shell that exercises the +terminal architecture before the product canvas, sidebar, or workspace exists. +GraphCode owns the top-level `HWND`, window procedure, and sole `GetMessageW` +loop. Winghostty owns two complete child surfaces. zmx owns the persistent +session/ConPTY lifetime. + +## Provider pins + +`provider-pins.json` records the accepted provider commits: + +- Winghostty `f5abc059e4ca58b376eb209313aca7784659c679` +- zmx `029e11d2b19162fb3bdf90c8270237d303b8bfb4` + +Both commits are published on dedicated branches in the public `coneilen` +provider repositories. The bootstrap creates detached, exact-revision +checkouts without copying provider source into GraphCode. + +## Build + +Build Winghostty's host artifact at its pinned local SHA, build zmx at its +pinned Windows integration SHA, then run: + +```powershell +zig build ` + -Dwinghostty-dir= ` + -Dwinghostty-lib=\zig-out\lib\winghostty-win32-host.lib ` + -Doptimize=ReleaseSafe +``` + +Set `GRAPHCODE_ZMX` to the pinned `zmx.exe` for the gate process. The default +surface commands are `zmx attach graphcode-terminal-gate-a` and +`zmx attach graphcode-terminal-gate-b`; `--same-session` deliberately shares +the A session and exercises zmx's shared-attach policy. + +## Gate behavior + +The `--smoke` mode focuses both surfaces independently, exercises DPI/IME/ +clipboard/accessibility events, types an `echo` command through Winghostty's +text/key callbacks, and verifies the resulting output arrives through the +per-surface `zmx attach` pipes. Attach stdout is accumulated into the +Winghostty terminal/accessibility text path and requests a real +`surface_render`/`surface_present` pair; any provider error fails the gate. + +Surface A is destroyed and recreated while B remains alive. Recreate stops and +reaps only the attach client, leaving the zmx session daemon persistent. +`--stress` repeats this cycle. Running the smoke process again verifies typed +output and VT history survive GraphCode exit and are visible after reattach. +The gate never calls GraphCode canvas/sidebar code and does not duplicate daemon +orchestration; the existing Windows daemon remains an independent service. +Each attach child is spawned with `GRAPHCODE_GATE_CWD`; the first-session +smoke sends the shell's `cd`/pwd query and verifies the repository root. +Cleanup checks every `zmx kill` exit, confirms the named registrations and +reported processes disappear, and fails the gate if cleanup is incomplete. +`GRAPHCODE_TERMINAL_GATE_INJECT_CLEANUP_FAILURE=1` is a failure-injection +contract used to prove cleanup errors cannot produce a green result. + +The provider owns the rendering/input/IME/clipboard/per-monitor-DPI/UIA +semantics. The gate owns the caller-side renderer lifecycle, focus policy, +per-surface attach transport, and a parsed VT cell snapshot that it submits +through `winghostty_surface_set_terminal_cells`; UI Automation text remains a +separate accessibility snapshot. The provider renderer draws those cells, so +typed echo and restored history are pixel/cell-observable rather than +accessibility-only. + +`Tools\windows\validate.ps1 -Task terminal-gate` runs the contract plus the +pinned-provider build and smoke. Missing or dirty provider roots fail the task; +there is no green validation result without the real provider smoke. + +## TDD and validation evidence + +The architecture contract was run RED before the gate files existed, then +GREEN after the Zig host, provider pins, and smoke harness were added. +`Tools\windows\Tests\TerminalGate.Tests.ps1` remains the fast architecture +contract check. The harness sends shell commands through zmx, verifies +`zmx history --vt` before and after independent and same-session restarts, and +runs the destroy/recreate stress cycle. + +At the accepted provider SHA, the Winghostty API lifecycle, input, pixel cell +render, and three-process renderer stress contracts pass. GraphCode full +Windows validation, formatting, privacy, TDD evidence, and the terminal-gate +smoke/stress harness are run only with clean pinned provider worktrees. diff --git a/investigation/spikes/windows-terminal-gate/build.zig b/investigation/spikes/windows-terminal-gate/build.zig new file mode 100644 index 00000000..7472cd49 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/build.zig @@ -0,0 +1,62 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{ + .default_target = .{ + .cpu_arch = .x86_64, + .os_tag = .windows, + .abi = .msvc, + }, + }); + const optimize = b.standardOptimizeOption(.{}); + + const winghostty_dir = b.option( + []const u8, + "winghostty-dir", + "Path to the exact local Winghostty provider worktree", + ) orelse { + const fail = b.addFail("pass -Dwinghostty-dir="); + b.getInstallStep().dependOn(&fail.step); + return; + }; + const winghostty_include = b.option( + []const u8, + "winghostty-include", + "Optional Winghostty include directory", + ) orelse b.pathJoin(&.{ winghostty_dir, "include" }); + const winghostty_lib = b.option( + []const u8, + "winghostty-lib", + "Optional Winghostty static host library", + ) orelse b.pathJoin(&.{ winghostty_dir, "zig-out", "lib", "winghostty-win32-host.lib" }); + + const module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + module.addIncludePath(.{ .cwd_relative = winghostty_include }); + + const exe = b.addExecutable(.{ + .name = "graphcode-terminal-gate", + .root_module = module, + }); + exe.addObjectFile(.{ .cwd_relative = winghostty_lib }); + exe.linkSystemLibrary("user32"); + exe.linkSystemLibrary("gdi32"); + exe.linkSystemLibrary("opengl32"); + exe.linkSystemLibrary("kernel32"); + exe.linkSystemLibrary("imm32"); + exe.linkSystemLibrary("oleaut32"); + exe.linkSystemLibrary("ole32"); + exe.linkSystemLibrary("uiautomationcore"); + exe.linkSystemLibrary("shell32"); + b.installArtifact(exe); + + const run_step = b.step("run", "Run the two-surface terminal gate"); + const run = b.addRunArtifact(exe); + run.step.dependOn(b.getInstallStep()); + if (b.args) |args| run.addArgs(args); + run_step.dependOn(&run.step); +} diff --git a/investigation/spikes/windows-terminal-gate/build.zig.zon b/investigation/spikes/windows-terminal-gate/build.zig.zon new file mode 100644 index 00000000..9322e992 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .graphcode_windows_terminal_gate, + .version = "0.1.0", + .fingerprint = 0x5de75fc22fa589cc, + .minimum_zig_version = "0.15.2", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "provider-pins.json", + "README.md", + }, +} diff --git a/investigation/spikes/windows-terminal-gate/provider-pins.json b/investigation/spikes/windows-terminal-gate/provider-pins.json new file mode 100644 index 00000000..5d3c9535 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/provider-pins.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "winghostty": { + "repository": "coneilen/winghostty", + "remoteUrl": "https://github.com/coneilen/winghostty.git", + "sha": "f5abc059e4ca58b376eb209313aca7784659c679", + "artifact": "zig-out/lib/winghostty-win32-host.lib", + "minimumZig": "0.15.2" + }, + "zmx": { + "repository": "coneilen/zmx", + "remoteUrl": "https://github.com/coneilen/zmx.git", + "sha": "029e11d2b19162fb3bdf90c8270237d303b8bfb4", + "artifact": "zig-out/bin/zmx.exe", + "minimumZig": "0.16.0" + }, + "localFallback": { + "enabled": false, + "remoteWorkflowBlocked": false, + "reason": "Exact provider commits are publicly available and bootstrapped by Tools/windows/bootstrap.ps1.", + "paths": [], + "upgrade": "Immutable provider artifacts may replace detached source checkouts without changing the host contract." + } +} diff --git a/investigation/spikes/windows-terminal-gate/src/main.zig b/investigation/spikes/windows-terminal-gate/src/main.zig new file mode 100644 index 00000000..c4fcea29 --- /dev/null +++ b/investigation/spikes/windows-terminal-gate/src/main.zig @@ -0,0 +1,1227 @@ +const std = @import("std"); + +const c = @cImport({ + @cDefine("_WIN32_WINNT", "0x0601"); + @cInclude("windows.h"); + @cInclude("winghostty/win32_host.h"); +}); + +const allocator = std.heap.c_allocator; +const HWND = c.HWND; +const HINSTANCE = c.HINSTANCE; +const LPARAM = c.LPARAM; +const LRESULT = c.LRESULT; +const LONG_PTR = c.LONG_PTR; +const UINT = c.UINT; +const WPARAM = c.WPARAM; +const DWORD = c.DWORD; +const BOOL = c.BOOL; + +const terminal_columns: usize = 120; +const terminal_rows: usize = 40; +const terminal_cell_count: usize = terminal_columns * terminal_rows; +const attach_restart_limit: usize = 16; + +const TerminalParserState = enum { + normal, + escape, + csi, + osc, +}; + +const SurfaceSlot = struct { + surface: ?*c.winghostty_surface = null, + last_surface: ?*c.winghostty_surface = null, + session_name: []const u8 = "", + attach: ?std.process.Child = null, + destroying: bool = false, + destroyed: bool = false, + redraws: usize = 0, + focus_events: usize = 0, + ime_events: usize = 0, + clipboard_events: usize = 0, + output_events: usize = 0, + input_bytes: usize = 0, + input_seen: bool = false, + output_seen: bool = false, + attach_restarts: usize = 0, + silent_attach_ticks: usize = 0, + terminal_buffer: [16 * 1024]u8 = undefined, + terminal_buffer_len: usize = 0, + terminal_cells: [terminal_cell_count]c.winghostty_terminal_cell = [_]c.winghostty_terminal_cell{ + .{ + .codepoint = 0, + .foreground = 0xE6E6E6, + .background = 0, + .flags = 0, + }, + } ** terminal_cell_count, + terminal_x: usize = 0, + terminal_y: usize = 0, + terminal_parser: TerminalParserState = .normal, + csi_value: usize = 0, + csi_have_value: bool = false, + csi_private: bool = false, +}; + +const App = struct { + hwnd: HWND = null, + instance: HINSTANCE = null, + host: ?*c.winghostty_host = null, + surfaces: [2]SurfaceSlot = .{ .{}, .{} }, + zmx_path: []const u8 = "zmx.exe", + cwd: []const u8 = ".", + smoke: bool = false, + stress: bool = false, + same_session: bool = false, + tick: usize = 0, + input_contracts_run: bool = false, + input_contracts_tick: usize = 0, + recreation_started_tick: usize = 0, + attach_stable_ticks: usize = 0, + recreate_count: usize = 0, + callbacksAfterDestroy: usize = 0, + lastRenderError: c.winghostty_result = c.WINGHOSTTY_OK, + renderFailures: usize = 0, + transportFailures: usize = 0, + lastTransportFailure: []const u8 = "", + totalInputBytes: usize = 0, + totalOutputEvents: usize = 0, + sameSession: bool = false, + session_buffers: [3][128]u8 = [_][128]u8{[_]u8{0} ** 128} ** 3, + session_lengths: [3]usize = .{ 0, 0, 0 }, + active_surface: usize = 0, + retired_surfaces: [16]?*c.winghostty_surface = [_]?*c.winghostty_surface{null} ** 16, + retired_surface_count: usize = 0, + ready: bool = false, +}; + +const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeTerminalGate"); +const window_title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows terminal gate"); +// The attach client is deliberately the real provider command: `zmx attach `. +// std.process.Child maps to CreateProcessW and owns the child handles. +const wm_gate_tick: UINT = c.WM_APP + 41; +const timer_id: usize = 41; +const gwlp_userdata: i32 = -21; + +fn appFromWindow(hwnd: HWND) ?*App { + const value = c.GetWindowLongPtrW(hwnd, gwlp_userdata); + if (value == 0) return null; + return @ptrFromInt(@as(usize, @bitCast(value))); +} + +fn appFromUserData(user_data: ?*anyopaque) ?*App { + return if (user_data) |value| @ptrCast(@alignCast(value)) else null; +} + +fn recordProviderError(app: *App, result: c.winghostty_result) void { + if (result != c.WINGHOSTTY_OK) { + app.renderFailures += 1; + if (app.lastRenderError == c.WINGHOSTTY_OK) { + app.lastRenderError = result; + } + } +} + +fn slotForSurface(app: *App, surface: *c.winghostty_surface) ?*SurfaceSlot { + for (&app.surfaces) |*slot| { + if (slot.surface == surface) return slot; + } + return null; +} + +fn surfaceIndex(app: *App, surface: *c.winghostty_surface) ?usize { + for (&app.surfaces, 0..) |*slot, index| { + if (slot.surface == surface) return index; + } + return null; +} + +fn isRetiredSurface(app: *App, surface: *c.winghostty_surface) bool { + for (app.retired_surfaces[0..app.retired_surface_count]) |retired| { + if (retired == surface) return true; + } + return false; +} + +fn callbackSlot( + app: *App, + surface: *c.winghostty_surface, +) ?*SurfaceSlot { + const slot = slotForSurface(app, surface) orelse { + if (isRetiredSurface(app, surface)) app.callbacksAfterDestroy += 1; + return null; + }; + if (slot.destroyed or slot.destroying) { + app.callbacksAfterDestroy += 1; + return null; + } + return slot; +} + +fn rememberRetiredSurface(app: *App, surface: *c.winghostty_surface) void { + if (app.retired_surface_count < app.retired_surfaces.len) { + app.retired_surfaces[app.retired_surface_count] = surface; + app.retired_surface_count += 1; + } +} + +fn renderSurface(app: *App, slot: *SurfaceSlot, surface: *c.winghostty_surface) void { + const make_current = c.winghostty_surface_make_current(surface); + recordProviderError(app, make_current); + const render = c.winghostty_surface_render(surface); + recordProviderError(app, render); + const present = c.winghostty_surface_present(surface); + recordProviderError(app, present); + const clear_current = c.winghostty_surface_clear_current(surface); + recordProviderError(app, clear_current); + slot.redraws += 1; +} + +fn onRedraw(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + const app = appFromUserData(user_data) orelse return; + const slot = callbackSlot(app, surface) orelse return; + renderSurface(app, slot, surface); +} + +fn onFocus( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + focused: u8, +) callconv(.c) void { + const app = appFromUserData(user_data) orelse return; + const slot = callbackSlot(app, surface) orelse return; + slot.focus_events += 1; + if (focused != 0) { + app.active_surface = surfaceIndex(app, surface) orelse app.active_surface; + for (&app.surfaces) |*other| { + if (other.surface) |other_surface| { + if (other_surface != surface) { + _ = c.winghostty_surface_set_focus(other_surface, 0); + } + } + } + } +} + +fn writeAttachInput( + app: *App, + surface: *c.winghostty_surface, + bytes: []const u8, +) void { + if (bytes.len == 0) return; + const slot = callbackSlot(app, surface) orelse return; + const child = &(slot.attach orelse { + app.transportFailures += 1; + app.lastTransportFailure = "no-attach"; + return; + }); + const stdin = child.stdin orelse { + app.transportFailures += 1; + app.lastTransportFailure = "no-stdin"; + return; + }; + stdin.writeAll(bytes) catch |err| { + std.debug.print("terminal gate stdin write failed: {s}\n", .{@errorName(err)}); + app.transportFailures += 1; + app.lastTransportFailure = "write"; + return; + }; + slot.input_bytes += bytes.len; + slot.input_seen = true; + app.totalInputBytes += bytes.len; +} + +fn onKey( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + event: *const c.winghostty_key_event, +) callconv(.c) void { + const app = appFromUserData(user_data) orelse return; + if (event.action == c.WINGHOSTTY_KEY_RELEASE) return; + const bytes: []const u8 = switch (event.virtual_key) { + c.VK_RETURN => "\r", + c.VK_BACK => "\x08", + c.VK_TAB => "\t", + c.VK_ESCAPE => "\x1b", + c.VK_UP => "\x1b[A", + c.VK_DOWN => "\x1b[B", + c.VK_RIGHT => "\x1b[C", + c.VK_LEFT => "\x1b[D", + else => return, + }; + writeAttachInput(app, surface, bytes); +} + +fn onText( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + text: [*:0]const u8, + length: u32, +) callconv(.c) void { + const app = appFromUserData(user_data) orelse return; + writeAttachInput(app, surface, text[0..length]); +} + +fn onImeUpdate( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + text: [*:0]const u8, + length: u32, + committed: u8, +) callconv(.c) void { + const app = appFromUserData(user_data) orelse return; + const slot = callbackSlot(app, surface) orelse return; + slot.ime_events += 1; + if (committed != 0) writeAttachInput(app, surface, text[0..length]); +} + +fn onClipboardWrite( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + format: u32, + text: [*:0]const u8, + length: u32, +) callconv(.c) void { + _ = format; + const app = appFromUserData(user_data) orelse return; + const slot = callbackSlot(app, surface) orelse return; + slot.clipboard_events += 1; + writeAttachInput(app, surface, text[0..length]); +} + +fn onExit( + user_data: ?*anyopaque, + surface: ?*c.winghostty_surface, + status: i32, +) callconv(.c) void { + _ = status; + const app = appFromUserData(user_data) orelse return; + const value = surface orelse return; + _ = callbackSlot(app, value); +} + +fn noOpTitle( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + title: [*:0]const u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = title; +} + +fn noOpCwd( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + cwd: [*:0]const u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = cwd; +} + +fn noOpBell(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn noOpNotification( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + notification: [*:0]const u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = notification; +} + +fn noOpFatal( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + error_code: c.winghostty_result, + message: [*:0]const u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = error_code; + _ = message; +} + +fn noOpDpi( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + dpi: u32, + scale: f32, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = dpi; + _ = scale; +} + +fn noOpMetrics( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + metrics: *const c.winghostty_cell_metrics, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = metrics; +} + +fn noOpAccessibilitySelection( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + start: u64, + end: u64, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = start; + _ = end; +} + +fn noOpImeStart(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn noOpImeEnd(user_data: ?*anyopaque, surface: *c.winghostty_surface) callconv(.c) void { + _ = user_data; + _ = surface; +} + +fn noOpMouse( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + event: *const c.winghostty_mouse_event, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = event; +} + +fn noOpSelection( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + event: *const c.winghostty_selection_event, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = event; +} + +fn noOpLink( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + url: [*:0]const u8, + hovered: u8, + clicked: u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = url; + _ = hovered; + _ = clicked; +} + +fn onPaste( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + text: [*:0]const u8, + length: u32, + bracketed: u8, +) callconv(.c) void { + _ = bracketed; + const app = appFromUserData(user_data) orelse return; + writeAttachInput(app, surface, text[0..length]); +} + +fn onClipboardRead( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + format: u32, + text: [*:0]const u8, + length: u32, +) callconv(.c) void { + _ = format; + const app = appFromUserData(user_data) orelse return; + writeAttachInput(app, surface, text[0..length]); +} + +fn noOpPaste( + user_data: ?*anyopaque, + surface: *c.winghostty_surface, + text: [*:0]const u8, + length: u32, + bracketed: u8, +) callconv(.c) void { + _ = user_data; + _ = surface; + _ = text; + _ = length; + _ = bracketed; +} + +fn initializeOptions(app: *App, index: usize) c.winghostty_surface_options_v2 { + var options: c.winghostty_surface_options_v2 = undefined; + c.winghostty_surface_options_v2_init(&options); + options.bounds.x = if (index == 0) 0 else 480; + options.bounds.y = 0; + options.bounds.width = 480; + options.bounds.height = 560; + options.visible = 1; + options.focus = if (index == 0) 1 else 0; + options.theme = c.WINGHOSTTY_THEME_DARK; + options.font_scale = 1.0; + options.user_data = @ptrCast(app); + options.callbacks.on_exit = @ptrCast(&onExit); + options.callbacks.on_title = @ptrCast(&noOpTitle); + options.callbacks.on_cwd = @ptrCast(&noOpCwd); + options.callbacks.on_bell = @ptrCast(&noOpBell); + options.callbacks.on_notification = @ptrCast(&noOpNotification); + options.callbacks.on_redraw = @ptrCast(&onRedraw); + options.callbacks.on_focus = @ptrCast(&onFocus); + options.callbacks.on_fatal_error = @ptrCast(&noOpFatal); + options.callbacks.on_dpi_changed = @ptrCast(&noOpDpi); + options.callbacks.on_metrics_changed = @ptrCast(&noOpMetrics); + options.callbacks.on_accessibility_selection = @ptrCast(&noOpAccessibilitySelection); + options.input_callbacks.on_key = @ptrCast(&onKey); + options.input_callbacks.on_text = @ptrCast(&onText); + options.input_callbacks.on_ime_start = @ptrCast(&noOpImeStart); + options.input_callbacks.on_ime_update = @ptrCast(&onImeUpdate); + options.input_callbacks.on_ime_end = @ptrCast(&noOpImeEnd); + options.input_callbacks.on_mouse = @ptrCast(&noOpMouse); + options.input_callbacks.on_selection = @ptrCast(&noOpSelection); + options.input_callbacks.on_link = @ptrCast(&noOpLink); + options.input_callbacks.on_paste = @ptrCast(&onPaste); + options.input_callbacks.on_clipboard_read = @ptrCast(&onClipboardRead); + options.input_callbacks.on_clipboard_write = @ptrCast(&onClipboardWrite); + options.input.cell_width = 8; + options.input.cell_height = 16; + options.input.selection_enabled = 1; + options.input.links_enabled = 1; + options.input.paste_protection = 1; + options.input.bracketed_paste = 1; + options.input.keyboard_layout = null; + return options; +} + +fn sessionName(app: *App, index: usize) []const u8 { + const slot = if (app.same_session) 2 else index; + return app.session_buffers[slot][0..app.session_lengths[slot]]; +} + +fn startSession(app: *App, name: []const u8, index: usize) !void { + // zmx attach starts the persistent daemon when the exact session name is + // absent, and reconnects to it when it already exists. + var attach_args = [_][]const u8{ app.zmx_path, "attach", name }; + var child = std.process.Child.init(&attach_args, allocator); + child.cwd = app.cwd; + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Pipe; + child.stderr_behavior = .Ignore; + try child.spawn(); + app.surfaces[index].attach = child; +} + +fn waitAttachClient(slot: *SurfaceSlot) void { + if (slot.attach) |*child| { + _ = child.kill() catch {}; + _ = child.wait() catch {}; + slot.attach = null; + } +} + +fn appendTerminalOutput(slot: *SurfaceSlot, bytes: []const u8) void { + if (bytes.len >= slot.terminal_buffer.len) { + const tail = bytes[bytes.len - slot.terminal_buffer.len ..]; + @memcpy(&slot.terminal_buffer, tail); + slot.terminal_buffer_len = slot.terminal_buffer.len; + return; + } + if (slot.terminal_buffer_len + bytes.len > slot.terminal_buffer.len) { + const overflow = + slot.terminal_buffer_len + bytes.len - slot.terminal_buffer.len; + std.mem.copyForwards( + u8, + slot.terminal_buffer[0 .. slot.terminal_buffer_len - overflow], + slot.terminal_buffer[overflow..slot.terminal_buffer_len], + ); + slot.terminal_buffer_len -= overflow; + } + @memcpy( + slot.terminal_buffer[slot.terminal_buffer_len..][0..bytes.len], + bytes, + ); + slot.terminal_buffer_len += bytes.len; +} + +fn feedTerminalOutput(app: *App, index: usize, bytes: []const u8) void { + const slot = &app.surfaces[index]; + const surface = slot.surface orelse return; + appendTerminalOutput(slot, bytes); + feedTerminalCells(slot, bytes); + const cells_result = c.winghostty_surface_set_terminal_cells( + surface, + terminal_columns, + terminal_rows, + &slot.terminal_cells, + terminal_cell_count, + ); + recordProviderError(app, cells_result); + const text = slot.terminal_buffer[0..slot.terminal_buffer_len]; + const text_result = c.winghostty_surface_notify_accessibility_text( + surface, + text.ptr, + text.len, + 0, + text.len, + 0, + 0, + text.len, + ); + recordProviderError(app, text_result); + const redraw_result = c.winghostty_surface_notify_redraw(surface); + recordProviderError(app, redraw_result); + slot.output_events += 1; + slot.output_seen = true; + app.totalOutputEvents += 1; +} + +fn clearTerminalCells(slot: *SurfaceSlot) void { + for (&slot.terminal_cells) |*cell| { + cell.* = .{ + .codepoint = 0, + .foreground = 0xE6E6E6, + .background = 0, + .flags = 0, + }; + } + slot.terminal_x = 0; + slot.terminal_y = 0; +} + +fn terminalAdvanceLine(slot: *SurfaceSlot) void { + slot.terminal_x = 0; + if (slot.terminal_y + 1 < terminal_rows) { + slot.terminal_y += 1; + return; + } + std.mem.copyForwards( + c.winghostty_terminal_cell, + slot.terminal_cells[0 .. terminal_cell_count - terminal_columns], + slot.terminal_cells[terminal_columns..], + ); + for (slot.terminal_cells[terminal_cell_count - terminal_columns ..]) |*cell| { + cell.* = .{ + .codepoint = 0, + .foreground = 0xE6E6E6, + .background = 0, + .flags = 0, + }; + } +} + +fn putTerminalCodepoint(slot: *SurfaceSlot, codepoint: u32) void { + if (slot.terminal_x >= terminal_columns) terminalAdvanceLine(slot); + slot.terminal_cells[slot.terminal_y * terminal_columns + slot.terminal_x] = .{ + .codepoint = codepoint, + .foreground = 0xE6E6E6, + .background = 0, + .flags = 0, + }; + slot.terminal_x += 1; +} + +fn finishCsi(slot: *SurfaceSlot, final: u8) void { + const value = if (slot.csi_have_value) slot.csi_value else 1; + switch (final) { + 'A' => slot.terminal_y -|= value, + 'B' => slot.terminal_y = @min(terminal_rows - 1, slot.terminal_y + value), + 'C' => slot.terminal_x = @min(terminal_columns, slot.terminal_x + value), + 'D' => slot.terminal_x -|= value, + 'G' => slot.terminal_x = @min(terminal_columns, if (slot.csi_have_value) slot.csi_value -| 1 else 0), + 'd' => slot.terminal_y = @min(terminal_rows - 1, if (slot.csi_have_value) slot.csi_value -| 1 else 0), + 'H', 'f' => { + const row = if (slot.csi_have_value) slot.csi_value else 1; + slot.terminal_y = @min(terminal_rows - 1, row -| 1); + slot.terminal_x = 0; + }, + 'J' => if (slot.csi_have_value and slot.csi_value == 2) clearTerminalCells(slot), + 'K' => { + const start = slot.terminal_y * terminal_columns + slot.terminal_x; + for (slot.terminal_cells[start..][0..(terminal_columns - slot.terminal_x)]) |*cell| { + cell.* = .{ + .codepoint = 0, + .foreground = 0xE6E6E6, + .background = 0, + .flags = 0, + }; + } + }, + else => {}, + } + slot.csi_value = 0; + slot.csi_have_value = false; + slot.csi_private = false; +} + +fn feedTerminalCells(slot: *SurfaceSlot, bytes: []const u8) void { + for (bytes) |byte| { + switch (slot.terminal_parser) { + .normal => switch (byte) { + 0x1B => slot.terminal_parser = .escape, + '\r' => slot.terminal_x = 0, + '\n' => terminalAdvanceLine(slot), + '\x08' => slot.terminal_x -|= 1, + '\t' => slot.terminal_x = @min(terminal_columns, (slot.terminal_x + 8) & ~@as(usize, 7)), + 0x20...0x7E => putTerminalCodepoint(slot, byte), + else => {}, + }, + .escape => switch (byte) { + '[' => { + slot.terminal_parser = .csi; + slot.csi_value = 0; + slot.csi_have_value = false; + slot.csi_private = false; + }, + ']' => slot.terminal_parser = .osc, + 'c' => { + clearTerminalCells(slot); + slot.terminal_parser = .normal; + }, + else => slot.terminal_parser = .normal, + }, + .csi => switch (byte) { + '?' => slot.csi_private = true, + '0'...'9' => { + slot.csi_have_value = true; + slot.csi_value = @min(9999, slot.csi_value * 10 + (byte - '0')); + }, + ';' => {}, + 0x40...0x7E => { + finishCsi(slot, byte); + slot.terminal_parser = .normal; + }, + else => {}, + }, + .osc => if (byte == 0x07) { + slot.terminal_parser = .normal; + } else if (byte == 0x1B) { + slot.terminal_parser = .escape; + }, + } + } +} + +fn readAttachOutput(app: *App, index: usize) bool { + const slot = &app.surfaces[index]; + const child = slot.attach orelse return false; + const stdout = child.stdout orelse return false; + var available: c.DWORD = 0; + if (c.PeekNamedPipe( + @ptrCast(stdout.handle), + null, + 0, + null, + &available, + null, + ) == 0) { + return false; + } + while (available > 0) { + var buffer: [4096]u8 = undefined; + var read: c.DWORD = 0; + const amount = @min(available, @as(c.DWORD, @intCast(buffer.len))); + if (c.ReadFile( + @ptrCast(stdout.handle), + @ptrCast(&buffer), + amount, + &read, + null, + ) == 0 or + read == 0) + { + break; + } + feedTerminalOutput(app, index, buffer[0..@intCast(read)]); + if (c.PeekNamedPipe( + @ptrCast(stdout.handle), + null, + 0, + null, + &available, + null, + ) == 0) { + break; + } + } + return true; +} + +fn waitForInitialAttachOutput(app: *App, index: usize) !void { + var restarts: usize = 0; + for (0..120) |_| { + const attach_alive = readAttachOutput(app, index); + if (app.surfaces[index].output_seen) return; + if (!attach_alive) { + if (restarts == attach_restart_limit) return error.InitialAttachRestartLimit; + waitAttachClient(&app.surfaces[index]); + try startSession(app, sessionName(app, index), index); + restarts += 1; + } + std.Thread.sleep(100 * std.time.ns_per_ms); + } + return error.InitialAttachOutputTimeout; +} + +fn createWinghosttySurface(app: *App, index: usize) !void { + var options = initializeOptions(app, index); + const result = c.winghostty_host_create_surface_v2( + app.host, + app.hwnd, + &options, + &app.surfaces[index].surface, + ); + if (result != c.WINGHOSTTY_OK or app.surfaces[index].surface == null) { + return error.WinghosttySurfaceCreateFailed; + } + const slot = &app.surfaces[index]; + slot.last_surface = slot.surface; + slot.session_name = sessionName(app, index); + slot.destroyed = false; + slot.destroying = false; + slot.redraws = 0; + slot.focus_events = 0; + slot.ime_events = 0; + slot.clipboard_events = 0; + slot.output_events = 0; + slot.input_bytes = 0; + slot.attach_restarts = 0; + slot.silent_attach_ticks = 0; + slot.terminal_buffer_len = 0; + clearTerminalCells(slot); + slot.terminal_parser = .normal; + slot.csi_value = 0; + slot.csi_have_value = false; + slot.csi_private = false; +} + +fn createSurface(app: *App, index: usize) !void { + try startSession(app, sessionName(app, index), index); + createWinghosttySurface(app, index) catch |err| { + waitAttachClient(&app.surfaces[index]); + return err; + }; +} + +fn destroyWinghosttySurface(app: *App, index: usize) void { + const slot = &app.surfaces[index]; + if (slot.surface) |surface| { + slot.destroying = true; + rememberRetiredSurface(app, surface); + _ = c.winghostty_surface_destroy(surface); + slot.surface = null; + slot.destroyed = true; + slot.destroying = false; + } +} + +// The gate creates two independent complete surfaces through +// winghostty_host_create_surface_v2: surface A and surface B. +fn destroySurface(app: *App, index: usize) void { + const slot = &app.surfaces[index]; + waitAttachClient(slot); + destroyWinghosttySurface(app, index); +} + +fn recreateSurface(app: *App, index: usize) !void { + destroyWinghosttySurface(app, index); + app.recreate_count += 1; + return createWinghosttySurface(app, index); +} + +fn resizeSurfaces(app: *App, width: i32, height: i32) void { + const half = @max(1, @divTrunc(width, 2)); + for (&app.surfaces, 0..) |*slot, index| { + if (slot.surface) |surface| { + var bounds = c.winghostty_rect{ + .x = if (index == 0) 0 else half, + .y = 0, + .width = @intCast(if (index == 0) half else width - half), + .height = @intCast(@max(1, height)), + }; + _ = c.winghostty_surface_set_bounds(surface, &bounds); + } + } +} + +fn sendTypedCommand(app: *App, index: usize, command: []const u8) void { + const surface = app.surfaces[index].surface orelse return; + const hwnd = c.winghostty_surface_get_hwnd(surface) orelse return; + for (command) |byte| { + _ = c.SendMessageW(hwnd, c.WM_CHAR, @intCast(byte), 0); + } + _ = c.SendMessageW(hwnd, c.WM_KEYDOWN, @intCast(c.VK_RETURN), 0); +} + +fn runInputContracts(app: *App) !void { + for (&app.surfaces, 0..) |*slot, index| { + const surface = slot.surface orelse return error.SurfaceMissing; + const surface_hwnd = + c.winghostty_surface_get_hwnd(surface) orelse return error.SurfaceMissing; + _ = c.winghostty_surface_set_focus(surface, 1); + _ = c.winghostty_surface_notify_dpi_changed(surface, if (index == 0) 96 else 144); + _ = c.winghostty_surface_notify_accessibility_name( + surface, + if (index == 0) "GraphCode terminal A" else "GraphCode terminal B", + ); + sendTypedCommand( + app, + index, + if (index == 0) + "echo GraphCode typed output A" + else + "echo GraphCode typed output B", + ); + _ = c.winghostty_surface_ime_update(surface, "IME", 3, 1); + _ = c.winghostty_surface_paste_text(surface, "safe paste", 10, 0); + _ = c.winghostty_surface_write_clipboard(surface, c.WINGHOSTTY_CLIPBOARD_TEXT, "clipboard", 9); + _ = c.SendMessageW( + surface_hwnd, + c.WM_KEYDOWN, + @intCast(c.VK_RETURN), + 0, + ); + var copied: [64]u8 = undefined; + var copied_length: u64 = 0; + _ = c.winghostty_surface_copy_accessibility_range( + surface, + 0, + 9, + &copied, + copied.len, + &copied_length, + ); + const redraw_result = c.winghostty_surface_notify_redraw(surface); + recordProviderError(app, redraw_result); + if (index != app.active_surface) { + _ = c.winghostty_surface_set_focus(surface, 0); + } + } + if (app.surfaces[app.active_surface].surface) |surface| { + _ = c.winghostty_surface_set_focus(surface, 1); + } +} + +fn restartBrokenAttach(app: *App, index: usize) !void { + const slot = &app.surfaces[index]; + if (slot.attach_restarts == attach_restart_limit) return error.AttachRestartLimit; + waitAttachClient(slot); + try startSession(app, sessionName(app, index), index); + slot.attach_restarts += 1; + slot.silent_attach_ticks = 0; +} + +fn attachStreamsReady(app: *const App) bool { + const output_ready = app.surfaces[0].output_seen and + (app.same_session or app.surfaces[1].output_seen); + return output_ready and app.attach_stable_ticks >= 5; +} + +fn failSmokeReadiness(app: *App, reason: []const u8) void { + std.debug.print( + "terminal gate readiness failure: {s} tick={d} output=({any},{any}) input=({any},{any})\n", + .{ + reason, + app.tick, + app.surfaces[0].output_seen, + app.surfaces[1].output_seen, + app.surfaces[0].input_seen, + app.surfaces[1].input_seen, + }, + ); + app.transportFailures += 1; + app.lastTransportFailure = reason; + _ = c.DestroyWindow(app.hwnd); +} + +fn tick(app: *App) void { + app.tick += 1; + var attach_alive = [2]bool{ + readAttachOutput(app, 0), + readAttachOutput(app, 1), + }; + if (!app.input_contracts_run) { + for (&attach_alive, 0..) |*alive, index| { + if (!alive.*) { + restartBrokenAttach(app, index) catch |err| { + std.debug.print( + "terminal gate attach restart failed: {s} surface={d}\n", + .{ @errorName(err), index }, + ); + failSmokeReadiness(app, "attach-restart"); + return; + }; + } else if (!app.same_session and !app.surfaces[index].output_seen) { + app.surfaces[index].silent_attach_ticks += 1; + if (app.surfaces[index].silent_attach_ticks >= 20) { + restartBrokenAttach(app, index) catch |err| { + std.debug.print( + "terminal gate silent attach restart failed: {s} surface={d}\n", + .{ @errorName(err), index }, + ); + failSmokeReadiness(app, "silent-attach-restart"); + return; + }; + alive.* = false; + } + } + } + if (attach_alive[0] and attach_alive[1]) { + app.attach_stable_ticks += 1; + } else { + app.attach_stable_ticks = 0; + } + } + if (!app.input_contracts_run and attachStreamsReady(app)) { + runInputContracts(app) catch |err| { + std.debug.print("terminal gate input contract failed: {s}\n", .{@errorName(err)}); + app.transportFailures += 1; + app.lastTransportFailure = "input-contract"; + _ = c.DestroyWindow(app.hwnd); + return; + }; + app.input_contracts_run = true; + app.input_contracts_tick = app.tick; + if (app.surfaces[1].surface) |surface| { + _ = c.winghostty_surface_set_focus(surface, 1); + recordProviderError(app, c.winghostty_surface_notify_redraw(surface)); + } + } + if (app.smoke and + app.input_contracts_run and + app.recreation_started_tick == 0 and + app.tick - app.input_contracts_tick >= 50) + { + recreateSurface(app, 0) catch |err| { + std.debug.print("terminal gate recreate failed: {s}\n", .{@errorName(err)}); + app.transportFailures += 1; + app.lastTransportFailure = "recreate"; + _ = c.DestroyWindow(app.hwnd); + return; + }; + app.recreation_started_tick = app.tick; + } + const recreation_elapsed = + if (app.recreation_started_tick == 0) 0 else app.tick - app.recreation_started_tick; + if (app.stress and + recreation_elapsed >= 2 and + recreation_elapsed < 2 + 16 * 2 and + recreation_elapsed % 2 == 0) + { + recreateSurface(app, 0) catch |err| { + std.debug.print("terminal gate stress recreate failed: {s}\n", .{@errorName(err)}); + app.transportFailures += 1; + app.lastTransportFailure = "stress-recreate"; + _ = c.DestroyWindow(app.hwnd); + return; + }; + } + if (app.smoke and !app.stress and recreation_elapsed == 18) { + _ = c.DestroyWindow(app.hwnd); + } + if (app.smoke and app.stress and recreation_elapsed == 36) { + _ = c.DestroyWindow(app.hwnd); + } + if (app.smoke and app.tick == 120 and !app.input_contracts_run) { + failSmokeReadiness(app, "attach-output-timeout"); + } +} + +fn windowProc(hwnd: HWND, message: UINT, wparam: WPARAM, lparam: LPARAM) callconv(.winapi) LRESULT { + var app = appFromWindow(hwnd); + if (message == c.WM_NCCREATE) { + const create = @as(*const c.CREATESTRUCTW, @ptrFromInt(@as(usize, @bitCast(lparam)))); + app = @ptrCast(@alignCast(create.lpCreateParams)); + if (app) |value| { + value.hwnd = hwnd; + _ = c.SetWindowLongPtrW(hwnd, gwlp_userdata, @intCast(@intFromPtr(value))); + } + } + const value = app orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); + switch (message) { + c.WM_SIZE => { + const bits: usize = @bitCast(lparam); + resizeSurfaces( + value, + @intCast(@as(u16, @truncate(bits))), + @intCast(@as(u16, @truncate(bits >> 16))), + ); + }, + c.WM_SETFOCUS => { + if (value.surfaces[value.active_surface].surface) |surface| { + _ = c.winghostty_surface_set_focus(surface, 1); + } + }, + c.WM_TIMER => if (wparam == timer_id) tick(value), + c.WM_APP + 41 => tick(value), + c.WM_CLOSE => { + _ = c.DestroyWindow(hwnd); + }, + c.WM_DESTROY => { + _ = c.KillTimer(hwnd, timer_id); + _ = c.SetWindowLongPtrW(hwnd, gwlp_userdata, 0); + c.PostQuitMessage(0); + }, + c.WM_NCDESTROY => _ = c.SetWindowLongPtrW(hwnd, gwlp_userdata, 0), + else => {}, + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); +} + +fn registerWindowClass(instance: HINSTANCE) !void { + var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); + window_class.lpfnWndProc = @ptrCast(&windowProc); + window_class.hInstance = instance; + window_class.lpszClassName = class_name.ptr; + window_class.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + if (c.RegisterClassW(&window_class) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) { + return error.WindowClassRegistrationFailed; + } +} + +fn createWindow(app: *App) !void { + try registerWindowClass(app.instance); + app.hwnd = c.CreateWindowExW( + 0, + class_name.ptr, + window_title.ptr, + c.WS_OVERLAPPEDWINDOW | c.WS_CLIPCHILDREN, + c.CW_USEDEFAULT, + c.CW_USEDEFAULT, + 980, + 620, + null, + null, + app.instance, + @ptrCast(app), + ) orelse return error.WindowCreationFailed; + _ = c.ShowWindow(app.hwnd, c.SW_SHOW); + _ = c.UpdateWindow(app.hwnd); + _ = c.SetTimer(app.hwnd, timer_id, 100, null); +} + +fn cleanup(app: *App) void { + destroySurface(app, 0); + destroySurface(app, 1); + if (app.host) |host| { + _ = c.winghostty_host_deinitialize(host); + app.host = null; + } + if (app.hwnd) |hwnd| { + if (c.IsWindow(hwnd) != 0) _ = c.DestroyWindow(hwnd); + app.hwnd = null; + } +} + +fn messageLoop(app: *App) !void { + var message: c.MSG = undefined; + while (true) { + const result = c.GetMessageW(&message, null, 0, 0); + if (result == 0) break; + if (result == -1) return error.MessageLoopFailed; + _ = c.TranslateMessage(&message); + _ = c.DispatchMessageW(&message); + } + if (app.callbacksAfterDestroy != 0) return error.CallbackAfterDestroy; + if (app.smoke and app.lastRenderError != c.WINGHOSTTY_OK) { + return error.RendererContractFailed; + } + if (app.smoke and app.transportFailures != 0) { + std.debug.print( + "terminal gate transport failure: {s} count={d}\n", + .{ app.lastTransportFailure, app.transportFailures }, + ); + return error.AttachTransportFailed; + } + if (app.smoke and + (app.totalOutputEvents == 0 or + app.totalInputBytes == 0 or + !app.surfaces[0].input_seen or + !app.surfaces[0].output_seen or + !app.surfaces[1].input_seen or + (!app.same_session and !app.surfaces[1].output_seen))) + { + std.debug.print( + "terminal gate I/O contract failure: output_events={d} input_bytes={d} output=({any},{any}) input=({any},{any}) restarts=({d},{d}) stable_ticks={d}\n", + .{ + app.totalOutputEvents, + app.totalInputBytes, + app.surfaces[0].output_seen, + app.surfaces[1].output_seen, + app.surfaces[0].input_seen, + app.surfaces[1].input_seen, + app.surfaces[0].attach_restarts, + app.surfaces[1].attach_restarts, + app.attach_stable_ticks, + }, + ); + return error.SessionIoContractFailed; + } +} + +fn hasArg(args: []const []const u8, value: []const u8) bool { + for (args) |arg| if (std.mem.eql(u8, arg, value)) return true; + return false; +} + +fn valueArg(args: []const []const u8, prefix: []const u8) ?[]const u8 { + for (args) |arg| { + if (std.mem.startsWith(u8, arg, prefix)) return arg[prefix.len..]; + } + return null; +} + +pub fn main() !void { + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + var app = try allocator.create(App); + defer allocator.destroy(app); + app.* = .{ + .instance = c.GetModuleHandleW(null), + .smoke = hasArg(args, "--smoke"), + .stress = hasArg(args, "--stress"), + .same_session = hasArg(args, "--same-session"), + .sameSession = hasArg(args, "--same-session"), + .zmx_path = valueArg(args, "--zmx=") orelse + std.process.getEnvVarOwned(allocator, "GRAPHCODE_ZMX") catch "zmx.exe", + .cwd = std.process.getEnvVarOwned(allocator, "GRAPHCODE_GATE_CWD") catch ".", + }; + const prefix_owned = std.process.getEnvVarOwned( + allocator, + "GRAPHCODE_TERMINAL_SESSION_PREFIX", + ) catch null; + const prefix = prefix_owned orelse "graphcode-terminal-gate"; + defer if (prefix_owned) |value| allocator.free(value); + for ([_][]const u8{ "-a", "-b", "-shared" }, 0..) |suffix, index| { + const written = std.fmt.bufPrint(&app.session_buffers[index], "{s}{s}", .{ prefix, suffix }) catch + return error.SessionNameTooLong; + app.session_lengths[index] = written.len; + } + + try createWindow(app); + defer cleanup(app); + if (c.winghostty_host_initialize(&app.host) != c.WINGHOSTTY_OK) { + return error.WinghosttyHostInitializeFailed; + } + try createSurface(app, 0); + try waitForInitialAttachOutput(app, 0); + try createSurface(app, 1); + app.ready = true; + if (app.smoke) _ = c.PostMessageW(app.hwnd, wm_gate_tick, 0, 0); + try messageLoop(app); +} diff --git a/investigation/spikes/zmx-conpty/README.md b/investigation/spikes/zmx-conpty/README.md new file mode 100644 index 00000000..2404a56b --- /dev/null +++ b/investigation/spikes/zmx-conpty/README.md @@ -0,0 +1,72 @@ +# zmx ConPTY persistence spike + +Standalone Windows feasibility spike; it does not use or modify GraphCode production code. +It combines: + +- ConPTY for a hosted `cmd.exe /Q /K` terminal. +- A reader thread for ConPTY output. +- Named Pipe IPC: `\\.\pipe\zmx-conpty-spike`. +- A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. +- Detach/reconnect snapshot behavior while the daemon remains alive. + +The daemon uses the current directory for its log and ready marker. + +## Build (PowerShell) + +```powershell +Set-Location \investigation\spikes\zmx-conpty +# Run from a Developer PowerShell or Developer Command Prompt. +cl /nologo /W4 /O2 conpty_spike.c /link /SUBSYSTEM:CONSOLE /OUT:conpty_spike.exe +``` + +The observed build exited `0`. + +## Run (PowerShell) + +```powershell +Set-Location \investigation\spikes\zmx-conpty +Remove-Item -Force -ErrorAction SilentlyContinue daemon.log,ready.txt +.\conpty_spike.exe start +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe status +.\conpty_spike.exe send 'echo BEFORE' +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe detach +.\conpty_spike.exe send 'echo DETACHED' +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe attach +.\conpty_spike.exe status +Get-Content ready.txt +Get-Content daemon.log -Tail 30 +.\conpty_spike.exe stop +Start-Sleep -Milliseconds 800 +.\conpty_spike.exe status # expected: connect failed gle=2 +``` + +## Observed rerun + +```text +start: daemon_process_pid=40672 +ready=ready.txt +STATUS pid=42544 running=1 output=160 conpty=1 named_pipe=1 job=1 +OK write +OK detached +OK write +SNAPSHOT 388 +...echo BEFORE +BEFORE... +...echo DETACHED +DETACHED... +STATUS pid=42544 running=1 output=388 conpty=1 named_pipe=1 job=1 +pid=42544 +pipe=\\.\pipe\zmx-conpty-spike +conpty=ok +job=kill-on-close +OK stopping +connect failed gle=2 +``` + +`daemon.log` also recorded `CreatePseudoConsole(80x25)`, the Named Pipe, +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, reader startup, both commands, and their +output. After stopping, the hosted child and daemon were absent and the Named +Pipe endpoint was closed. diff --git a/investigation/spikes/zmx-conpty/conpty_spike.c b/investigation/spikes/zmx-conpty/conpty_spike.c new file mode 100644 index 00000000..bb6e84db --- /dev/null +++ b/investigation/spikes/zmx-conpty/conpty_spike.c @@ -0,0 +1,379 @@ +#define _WIN32_WINNT 0x0A00 +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include + +#define BASE_DIR L"." +#define PIPE_NAME L"\\\\.\\pipe\\zmx-conpty-spike" +#define READY_FILE L"ready.txt" +#define LOG_FILE L"daemon.log" +#define OUTPUT_CAP 262144 + +typedef struct Server { + HANDLE hpc; + HANDLE in_read; + HANDLE in_write; + HANDLE out_read; + HANDLE out_write; + HANDLE child; + DWORD child_pid; + HANDLE job; + HANDLE reader; + CRITICAL_SECTION lock; + char output[OUTPUT_CAP]; + size_t output_len; + volatile LONG running; + FILE *log; + int lock_initialized; +} Server; + +static void log_line(Server *s, const char *prefix, const char *data, size_t len) { + if (!s->log) return; + EnterCriticalSection(&s->lock); + fprintf(s->log, "%s", prefix); + fwrite(data, 1, len, s->log); + fputs("\n", s->log); + fflush(s->log); + LeaveCriticalSection(&s->lock); +} + +static DWORD WINAPI reader_thread(void *arg) { + Server *s = (Server *)arg; + EnterCriticalSection(&s->lock); + fprintf(s->log, "[reader] started\n"); + fflush(s->log); + LeaveCriticalSection(&s->lock); + char buf[4096]; + DWORD n = 0; + while (ReadFile(s->out_read, buf, sizeof(buf), &n, NULL) && n > 0) { + EnterCriticalSection(&s->lock); + size_t keep = n; + if (keep > OUTPUT_CAP - s->output_len) keep = OUTPUT_CAP - s->output_len; + if (keep) { + memcpy(s->output + s->output_len, buf, keep); + s->output_len += keep; + } + LeaveCriticalSection(&s->lock); + log_line(s, "[output] ", buf, n); + } + DWORD err = GetLastError(); + EnterCriticalSection(&s->lock); + fprintf(s->log, "[reader] exited gle=%lu\n", (unsigned long)err); + fflush(s->log); + LeaveCriticalSection(&s->lock); + InterlockedExchange(&s->running, 0); + return 0; +} + +static int write_all(HANDLE h, const void *data, DWORD len) { + const char *p = (const char *)data; + while (len) { + DWORD n = 0; + if (!WriteFile(h, p, len, &n, NULL) || n == 0) return 0; + p += n; + len -= n; + } + return 1; +} + +static int write_ready(Server *s) { + FILE *f = _wfopen(READY_FILE, L"w, ccs=UTF-8"); + if (!f) return 0; + fwprintf(f, L"pid=%lu\npipe=%s\nconpty=ok\njob=kill-on-close\n", (unsigned long)s->child_pid, PIPE_NAME); + fclose(f); + return 1; +} + +static void remove_ready(void) { + DeleteFileW(READY_FILE); +} + +static int create_server(Server *s) { +#define STEP_FAIL(msg) do { fprintf(stderr, "create_server: %s gle=%lu\n", msg, (unsigned long)GetLastError()); return 0; } while (0) + SECURITY_ATTRIBUTES sa; + memset(&sa, 0, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.bInheritHandle = FALSE; + + s->job = CreateJobObjectW(NULL, NULL); + if (!s->job) STEP_FAIL("CreateJobObject"); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION ji; + memset(&ji, 0, sizeof(ji)); + ji.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(s->job, JobObjectExtendedLimitInformation, &ji, sizeof(ji))) STEP_FAIL("SetInformationJobObject"); + + if (!CreatePipe(&s->in_read, &s->in_write, &sa, 0)) STEP_FAIL("CreatePipe input"); + if (!CreatePipe(&s->out_read, &s->out_write, &sa, 0)) STEP_FAIL("CreatePipe output"); + SetHandleInformation(s->in_read, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->in_write, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->out_read, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(s->out_write, HANDLE_FLAG_INHERIT, 0); + + HRESULT hr = CreatePseudoConsole((COORD){80, 25}, s->in_read, s->out_write, 0, &s->hpc); + if (FAILED(hr)) STEP_FAIL("CreatePseudoConsole"); + + SIZE_T attr_size = 0; + InitializeProcThreadAttributeList(NULL, 1, 0, &attr_size); + LPPROC_THREAD_ATTRIBUTE_LIST attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(GetProcessHeap(), 0, attr_size); + if (!attrs) STEP_FAIL("HeapAlloc attrs"); + if (!InitializeProcThreadAttributeList(attrs, 1, 0, &attr_size)) STEP_FAIL("InitializeProcThreadAttributeList"); + if (!UpdateProcThreadAttribute(attrs, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + s->hpc, sizeof(s->hpc), NULL, NULL)) STEP_FAIL("UpdateProcThreadAttribute"); + + STARTUPINFOEXW si; + PROCESS_INFORMATION pi; + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.StartupInfo.cb = sizeof(si); + si.lpAttributeList = attrs; + wchar_t cmdline[] = L"cmd.exe /Q /K"; + DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT; + BOOL ok = CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, flags, NULL, NULL, + &si.StartupInfo, &pi); + DeleteProcThreadAttributeList(attrs); + HeapFree(GetProcessHeap(), 0, attrs); + if (!ok) STEP_FAIL("CreateProcessW child"); + + s->child = pi.hProcess; + s->child_pid = pi.dwProcessId; + CloseHandle(pi.hThread); + // The handles supplied to CreatePseudoConsole must be released after the + // hosted process is created; retain only the host-side pipe ends. + CloseHandle(s->in_read); s->in_read = NULL; + CloseHandle(s->out_write); s->out_write = NULL; + if (!AssignProcessToJobObject(s->job, s->child)) STEP_FAIL("AssignProcessToJobObject"); + + s->log = _wfopen(LOG_FILE, L"w"); + if (!s->log) STEP_FAIL("open log"); + fprintf(s->log, "[daemon] child_pid=%lu\n", (unsigned long)s->child_pid); + fprintf(s->log, "[daemon] ConPTY=CreatePseudoConsole(80x25)\n"); + fprintf(s->log, "[daemon] JobObject=JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE\n"); + fprintf(s->log, "[daemon] NamedPipe=%ls\n", PIPE_NAME); + fflush(s->log); + + InitializeCriticalSection(&s->lock); + s->lock_initialized = 1; + InterlockedExchange(&s->running, 1); + s->reader = CreateThread(NULL, 0, reader_thread, s, 0, NULL); + if (!s->reader) STEP_FAIL("CreateThread reader"); + if (!write_ready(s)) STEP_FAIL("write_ready"); + return 1; +} + +static int read_line(HANDLE h, char *buf, DWORD cap) { + DWORD used = 0; + while (used + 1 < cap) { + DWORD n = 0; + if (!ReadFile(h, buf + used, 1, &n, NULL) || n == 0) return 0; + if (buf[used++] == '\n') break; + } + buf[used] = 0; + return 1; +} + +static void trim_line(char *line) { + size_t n = strlen(line); + while (n && (line[n - 1] == '\r' || line[n - 1] == '\n')) line[--n] = 0; +} + +static void respond(HANDLE pipe, const char *data, DWORD len) { + write_all(pipe, data, len); + FlushFileBuffers(pipe); +} + +static void handle_request(Server *s, HANDLE pipe, char *line) { + trim_line(line); + if (strncmp(line, "WRITE ", 6) == 0) { + const char *text = line + 6; + char command[4096]; + int n = _snprintf_s(command, sizeof(command), _TRUNCATE, "%s\r", text); + int wrote = (n > 0) && write_all(s->in_write, command, (DWORD)n); + EnterCriticalSection(&s->lock); + fprintf(s->log, "[ipc] WRITE bytes=%d ok=%d gle=%lu text=%s\n", n, wrote, (unsigned long)GetLastError(), text); + fflush(s->log); + LeaveCriticalSection(&s->lock); + if (wrote) respond(pipe, "OK write\n", 9); + else respond(pipe, "ERR write\n", 10); + return; + } + if (strcmp(line, "DETACH") == 0) { + respond(pipe, "OK detached\n", 13); + log_line(s, "[ipc] ", "DETACH", 6); + return; + } + if (strcmp(line, "SNAPSHOT") == 0) { + EnterCriticalSection(&s->lock); + char header[64]; + int hn = _snprintf_s(header, sizeof(header), _TRUNCATE, "SNAPSHOT %zu\n", s->output_len); + write_all(pipe, header, (DWORD)hn); + if (s->output_len) write_all(pipe, s->output, (DWORD)s->output_len); + LeaveCriticalSection(&s->lock); + FlushFileBuffers(pipe); + return; + } + if (strcmp(line, "STATUS") == 0) { + char status[256]; + EnterCriticalSection(&s->lock); + size_t len = s->output_len; + LeaveCriticalSection(&s->lock); + int n = _snprintf_s(status, sizeof(status), _TRUNCATE, + "STATUS pid=%lu running=%ld output=%zu conpty=1 named_pipe=1 job=1\n", + (unsigned long)s->child_pid, (long)s->running, len); + respond(pipe, status, (DWORD)n); + return; + } + if (strcmp(line, "QUIT") == 0) { + respond(pipe, "OK stopping\n", 13); + InterlockedExchange(&s->running, 0); + return; + } + respond(pipe, "ERR unknown\n", 13); +} + +static int server_loop(Server *s) { + while (s->running) { + HANDLE pipe = CreateNamedPipeW( + PIPE_NAME, PIPE_ACCESS_DUPLEX, PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, 65536, 65536, 0, NULL); + if (pipe == INVALID_HANDLE_VALUE) return 0; + BOOL connected = ConnectNamedPipe(pipe, NULL); + if (!connected && GetLastError() != ERROR_PIPE_CONNECTED) { + CloseHandle(pipe); + continue; + } + char line[8192]; + if (read_line(pipe, line, sizeof(line))) handle_request(s, pipe, line); + FlushFileBuffers(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + } + return 1; +} + +static void cleanup_server(Server *s) { + InterlockedExchange(&s->running, 0); + if (s->child) { + WaitForSingleObject(s->child, 200); + DWORD code = STILL_ACTIVE; + if (GetExitCodeProcess(s->child, &code) && code == STILL_ACTIVE) TerminateProcess(s->child, 0); + WaitForSingleObject(s->child, 1000); + CloseHandle(s->child); + } + if (s->reader) { + CloseHandle(s->out_write); + WaitForSingleObject(s->reader, 1000); + CloseHandle(s->reader); + } + if (s->hpc) ClosePseudoConsole(s->hpc); + if (s->in_read) CloseHandle(s->in_read); + if (s->in_write) CloseHandle(s->in_write); + if (s->out_read) CloseHandle(s->out_read); + if (s->out_write) CloseHandle(s->out_write); + if (s->job) CloseHandle(s->job); + if (s->log) fclose(s->log); + DeleteFileW(READY_FILE); + DeleteCriticalSection(&s->lock); +} + +static int connect_pipe(HANDLE *out) { + for (int i = 0; i < 100; ++i) { + HANDLE h = CreateFileW(PIPE_NAME, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); + if (h != INVALID_HANDLE_VALUE) { *out = h; return 1; } + Sleep(50); + } + return 0; +} + +static int client_request(const char *request, int raw_snapshot) { + HANDLE pipe; + if (!connect_pipe(&pipe)) { fprintf(stderr, "connect failed gle=%lu\n", (unsigned long)GetLastError()); return 2; } + if (!write_all(pipe, request, (DWORD)strlen(request))) { CloseHandle(pipe); return 3; } + FlushFileBuffers(pipe); + char buf[8192]; + DWORD n; + int rc = 0; + while (ReadFile(pipe, buf, sizeof(buf), &n, NULL) && n) { + if (raw_snapshot) { + DWORD out_n = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buf, n, &out_n, NULL); + } else { + fwrite(buf, 1, n, stdout); + fflush(stdout); + } + } + CloseHandle(pipe); + return rc; +} + +static int wait_ready(void) { + for (int i = 0; i < 100; ++i) { + if (GetFileAttributesW(READY_FILE) != INVALID_FILE_ATTRIBUTES) return 1; + Sleep(50); + } + return 0; +} + +static int start_daemon(void) { + DeleteFileW(READY_FILE); + DeleteFileW(LOG_FILE); + wchar_t path[MAX_PATH]; + DWORD n = GetModuleFileNameW(NULL, path, MAX_PATH); + if (!n || n >= MAX_PATH) return 2; + wchar_t cmdline[2 * MAX_PATH]; + _snwprintf_s(cmdline, _countof(cmdline), _TRUNCATE, L"\"%s\" daemon", path); + STARTUPINFOW si; + PROCESS_INFORMATION pi; + memset(&si, 0, sizeof(si)); + memset(&pi, 0, sizeof(pi)); + si.cb = sizeof(si); + if (!CreateProcessW(path, cmdline, NULL, NULL, FALSE, CREATE_NO_WINDOW, + NULL, BASE_DIR, &si, &pi)) { + fprintf(stderr, "CreateProcess daemon failed gle=%lu\n", (unsigned long)GetLastError()); + return 3; + } + printf("daemon_process_pid=%lu\n", (unsigned long)pi.dwProcessId); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + if (!wait_ready()) { fprintf(stderr, "daemon did not become ready\n"); return 4; } + printf("ready=%ls\n", READY_FILE); + return 0; +} + +int wmain(int argc, wchar_t **argv) { + if (argc < 2) { + fwprintf(stderr, L"usage: start|daemon|send |detach|attach|snapshot|status|stop\n"); + return 1; + } + if (wcscmp(argv[1], L"daemon") == 0) { + Server s; + memset(&s, 0, sizeof(s)); + if (!create_server(&s)) { + fprintf(stderr, "create_server failed gle=%lu\n", (unsigned long)GetLastError()); + cleanup_server(&s); + return 10; + } + server_loop(&s); + cleanup_server(&s); + return 0; + } + if (wcscmp(argv[1], L"start") == 0) return start_daemon(); + if (wcscmp(argv[1], L"send") == 0 && argc >= 3) { + char text[4096]; + int n = WideCharToMultiByte(CP_UTF8, 0, argv[2], -1, text, sizeof(text), NULL, NULL); + if (!n) return 2; + char req[4200]; + _snprintf_s(req, sizeof(req), _TRUNCATE, "WRITE %s\n", text); + return client_request(req, 0); + } + if (wcscmp(argv[1], L"detach") == 0) return client_request("DETACH\n", 0); + if (wcscmp(argv[1], L"attach") == 0 || wcscmp(argv[1], L"snapshot") == 0) return client_request("SNAPSHOT\n", 1); + if (wcscmp(argv[1], L"status") == 0) return client_request("STATUS\n", 0); + if (wcscmp(argv[1], L"stop") == 0) return client_request("QUIT\n", 0); + fwprintf(stderr, L"unknown command\n"); + return 1; +} diff --git a/investigation/swift-windows-portability.md b/investigation/swift-windows-portability.md new file mode 100644 index 00000000..f26c330d --- /dev/null +++ b/investigation/swift-windows-portability.md @@ -0,0 +1,149 @@ +# Swift Windows portability audit + +Tested on Windows 11 with Swift 6.3.3 (`x86_64-unknown-windows-msvc`). + +## Result + +- `GraphcodeKit` contains 62 Swift files. +- 39 files (62.9%) are assessed as portable unchanged. +- 17 files (27.4%) retain shared behavior but need a platform/path/process/transport abstraction. +- 6 files (9.7%) are platform implementations and need Windows counterparts. +- No third-party Swift dependency blocker was found. `IdentifiedCollections` 1.1.1 and its `swift-collections` dependency built on Windows. +- A compiler-tested 31-file domain subset built and passed JSON/settings tests. This is 91.2% of `Domain/`; the three excluded files are cross-layer or path/shell coupled. +- The full package reached GraphCode sources and stopped at the unconditional `import Darwin` in `PTYProcessSession.swift`. + +Categories: + +- **A**: portable unchanged. +- **B**: shared behavior, portable after a small explicit abstraction or path correction. +- **C**: platform implementation; retain a Darwin version and add a Windows version. + +## File inventory + +| File | Category | Evidence / required change | +|---|---:|---| +| `CLI/GraphcodeCommand.swift` | A | Pure parsing/rendering over domain and daemon protocol values. | +| `DaemonBootstrap.swift` | C | launchd plist, `launchctl`, quarantine xattr, app-bundle helper layout. Add a per-user Windows startup/install host. | +| `Domain/AttentionRollup.swift` | A | Pure value derivation. | +| `Domain/BackendCapabilities.swift` | A | Pure capability values. | +| `Domain/BackendCommand.swift` | B | Domain layer calls `PresenceHooks.codexNotifyOverride`; separate OS-neutral backend policy from shell-specific hook arguments. | +| `Domain/CLISessionBackendKind.swift` | A | Pure enum/capabilities. | +| `Domain/CycleGuard.swift` | A | Codable value. | +| `Domain/EdgeCondition.swift` | A | Codable enum. | +| `Domain/EdgeKind.swift` | A | Codable enum. | +| `Domain/EdgeSpec.swift` | A | Codable value. | +| `Domain/GoalSpec.swift` | A | Codable value. | +| `Domain/GraphcodeSettings.swift` | A | Codable settings; Windows-specific defaults can be injected outside the type. | +| `Domain/LoopEdge.swift` | A | Codable value. | +| `Domain/LoopGraph.swift` | A | Built on Windows with `IdentifiedCollections`. | +| `Domain/LoopGraphScope.swift` | A | Pure scope/value logic. | +| `Domain/LoopNode.swift` | A | Built on Windows. | +| `Domain/LoopState.swift` | A | Codable state. | +| `Domain/LoopType.swift` | A | Codable enum and prompt composition. | +| `Domain/MetricSample.swift` | A | Pure value/trend logic. | +| `Domain/ModelTier.swift` | A | Codable enum. | +| `Domain/NodeDraft.swift` | A | Pure validation/value logic. | +| `Domain/NodeUpdate.swift` | A | Codable value. | +| `Domain/PayloadTransform.swift` | A | Codable enum. | +| `Domain/PilotState.swift` | A | Codable enum. | +| `Domain/Presence.swift` | A | Codable values. | +| `Domain/ProjectRef.swift` | A | Windows path strings round-trip unchanged. | +| `Domain/RemoteBootMarker.swift` | A | Pure marker parsing. | +| `Domain/RemoteProjectLocation.swift` | B | Remote path is intentionally POSIX, but local SSH executable/control-socket assumptions are macOS-specific. | +| `Domain/SafeArgument.swift` | A | Pure validation. | +| `Domain/SessionBriefing.swift` | B | Shared text, but fixed `~/.graphcode/bin/graphcode` and shell command examples need platform injection. | +| `Domain/ShellPredicate.swift` | A | Pure value. | +| `Domain/SSHReconnectLoop.swift` | B | Generates a `/bin/sh` retry loop; retain behavior behind a remote-shell strategy. | +| `Domain/TerminalLayout.swift` | A | Built on Windows with `IdentifiedCollections`. | +| `Domain/UsageSample.swift` | A | Codable value. | +| `Domain/WorktreeHygiene.swift` | A | Pure policy/value logic. | +| `Domain/WorktreeRef.swift` | A | Codable value. | +| `GraphStore.swift` | B | Orchestration is shared, but it stores raw `Int32` descriptors and calls `FramedMessageIO` directly. Store a send-capable connection instead. | +| `GraphcodeSettingsStore.swift` | A | Foundation JSON I/O is portable once `SupportDirectory` is corrected. | +| `IPC/DaemonProtocol.swift` | A | Codable command/event protocol is transport-independent. | +| `IPC/DaemonSocketClient.swift` | C | AF_UNIX, `sockaddr_un`, POSIX timeout and errno behavior. Add a Named Pipe client. | +| `IPC/DaemonSocketPath.swift` | B | Replace socket URL with a platform endpoint identity; retain support-dir/worktree isolation semantics. | +| `IPC/FramedMessageIO.swift` | B | Four-byte big-endian framing is reusable; raw POSIX `read`/`write` must become a byte-stream abstraction. | +| `ProjectPersistence.swift` | B | Replacing `/` only leaves `:` and `\` in Windows filenames. Use a stable hash plus optional readable suffix. | +| `ProjectRegistry.swift` | B | Rejects every drive-letter path via `hasPrefix("/")`; also owns raw descriptors. Use URL/path APIs and abstract connections. | +| `QuickChatStore.swift` | A | Foundation JSON I/O. | +| `Sessions/AgentEnvironment.swift` | B | `unsetenv` is not a portable public strategy. Build sanitized child environments and use a small Windows process-environment helper only where unavoidable. | +| `Sessions/CLISessionBackend.swift` | B | Shared facade, but defaults bind directly to `ZmxSessionLauncher` and shell/process implementations. Inject a session service. | +| `Sessions/CodexSessionLog.swift` | B | Foundation I/O is portable; `/` leaf parsing and zmx/process coupling need URL/platform helpers. | +| `Sessions/CopilotSessionLog.swift` | B | Foundation I/O is portable; `/` leaf parsing, Windows state locations, and zmx/process coupling need helpers. | +| `Sessions/MessageBus.swift` | A | Pure delivery policy and message construction. | +| `Sessions/NodeMemory.swift` | A | Foundation file I/O; default root follows corrected `SupportDirectory`. | +| `Sessions/PTYProcessSession.swift` | C | `Darwin`, `openpty`, `fcntl`, POSIX descriptors. Replace control-command use with a pipe-based process runner; interactive Windows PTY work belongs in zmx/ConPTY. | +| `Sessions/PresenceHooks.swift` | B | Shared lifecycle mapping, but generated hook bodies use `/bin/sh`, `sed`, `head`, shell quoting, and POSIX paths. Generate backend/OS-specific hook commands. | +| `Sessions/RemoteEnsureGate.swift` | A | Pure actor/lease logic. | +| `Sessions/RemoteGraphAccess.swift` | C | Embedded POSIX Python shim, AF_UNIX, chmod, shebangs, and Unix socket forwarding. Defer from native Windows v1 or add a separate Windows remote transport. | +| `Sessions/RemoteSocketForwarder.swift` | C | `/bin/sh`, `/usr/bin/ssh`, Unix remote socket forwarding, `kill -0`. | +| `Sessions/SessionIDStore.swift` | A | Foundation file I/O; default root follows corrected `SupportDirectory`. | +| `Sessions/ShellPredicateEvaluator.swift` | C | Hard-coded `/bin/zsh` and POSIX shell evaluation. Add a Windows predicate runner with an explicit shell policy. | +| `Sessions/ZmxLocator.swift` | B | Select `zmx.exe` and the Windows installation root. | +| `Sessions/ZmxSessionLauncher.swift` | B | Keep session/backend policy shared, but extract local process execution, shell invocation, quoting, path layout, hooks, and remote execution. | +| `SupportDirectory.swift` | B | Absolute Windows overrides are treated as relative (`C:\...` becomes `/C:/...`). Define platform roots and use URL path classification. | +| `TerminalLayoutStore.swift` | A | Foundation JSON I/O. | + +## Compiler and behavior evidence + +### Portable domain + +`investigation/spikes/swift-portable` compiles 31 domain files on Windows and tests: + +- graph JSON round-trip with `C:\Projects\GraphCode Demo` +- settings JSON round-trip + +Both tests pass. + +### Current path behavior + +`investigation/spikes/swift-paths` reproduces: + +```text +registryAcceptsPath=false +supportOverrideResolved=/D:/GraphCodeState +persistenceFileName=C:\Projects\GraphCode Demo.json +``` + +### Important correction to the original handoff + +`PTYProcessSession` is not only an old interactive PTY path. `ZmxSessionLauncher`, +Copilot/Codex probes, remote probes, label reads, sends, kills, and presence queries use +it as a general subprocess runner. Windows therefore needs: + +1. a pipe-based cross-platform `ProcessRunner` for non-interactive commands; and +2. ConPTY inside zmx for persistent interactive sessions. + +Trying to make all those short-lived control commands use ConPTY would preserve an +accidental macOS implementation detail and add unnecessary complexity. + +## Recommended first extraction + +1. `DaemonConnection` with `send(Data)`, lifecycle, and stable identity. +2. `ByteStream` framing independent of POSIX descriptors/HANDLEs. +3. `ProcessRunner` for direct executable argv, cwd, environment, output, timeout, and cancellation. +4. `ShellStrategy` for zsh, `cmd.exe`, PowerShell, and remote POSIX shells. +5. `PlatformPaths` for support/bin/hooks/session/log locations and safe persistence keys. +6. `SessionService` separating shared zmx/backend policy from OS command construction. + +`graphcoded` can remain Swift, but not “entirely shared except its socket main.” Its +orchestration can remain shared; transport host, startup, process/shell services, paths, +and remote forwarding require explicit platform implementations. + +## Platform implementation baseline + +The first production platform seam now lives in `GraphcodeKit/Sources/Platform`: + +- `WindowsPlatformPaths` and `DarwinPlatformPaths` provide canonical local paths, + support/bin/hooks/session roots, and versioned SHA-256 persistence keys. +- `FoundationProcessRunner` preserves direct argv, working directory, environment, + standard input/output/error, timeout, and cancellation behavior without mutating the + parent process. +- `WindowsShellStrategy` classifies native executables, `.cmd`/`.bat`, and `.ps1` + launches without translating arguments through an incidental shell. + +The root `Package.swift` now contains the production Windows `GraphcodeKit`, `graphcoded`, +and `graphcode` targets plus transport tests. Use +`pwsh Tools/windows/validate.ps1 -Task swift-production` for focused release-build, +test, and CLI-runtime validation; `-Task all` includes it. diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md new file mode 100644 index 00000000..670ba69a --- /dev/null +++ b/investigation/ui-parity-matrix.md @@ -0,0 +1,172 @@ +# Windows UI parity ledger + +This is a source-derived completion ledger, not a requirements sketch. A row is +`Validated` only when the Windows implementation exposes the same user-visible +information and actions as macOS and has runtime evidence. Platform-native chrome may +differ, but hiding a feature behind an undocumented shortcut or replacing a structured +screen with raw protocol fields is not parity. + +Statuses: + +- `Validated`: source mapping, automated coverage, and live walkthrough agree. +- `Partial`: some behavior exists, but visible controls, state, or interaction is absent + or materially different. +- `Missing`: no equivalent reachable Windows surface. +- `Blocked`: requires a deliberate platform decision or unavailable dependency. +- `Divergent`: Windows exposes a different product concept in the place where the macOS + surface belongs; it must be separated or redesigned before parity. + +## Application shell and navigation + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit project, overview, Quick Chats, and workspace destinations now exist. Live stub walkthrough verified project → overview → full workspace → Show in Graph with the sidebar retained; destination-specific toolbar and accessibility semantics remain incomplete | Partial | +| Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | The native header now exposes clickable needs-you and reclaimable-worktree chips, a visible Ctrl+P jump affordance, and a contextual loop-panel toggle alongside status. Focused hit-testing and a real populated fixture validate the controls; native focus/UIA semantics and macOS visual treatment remain incomplete | Partial | +| Jump palette | Search field, ranked cross-project results, type/state/project context, mouse and keyboard selection | Ctrl+P and Ctrl+J open a native modal palette with live exact-ID, exact-title, title-prefix, and substring ranking across projects. Results visibly include project, loop type, and state; Up/Down, Return, Escape, and mouse double-click are supported. The deterministic UIA gate verifies a visible search field, contextual cross-project results, and keyboard navigation changing the selected loop. | Validated | +| File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed and the five readable runtime groups were probed; project-management and contextual parity remains incomplete | Partial | +| Help menu | GraphCode Basics and normal About entry | The live Help menu exposes GraphCode Basics, which reopens onboarding, and About GraphCode, which opens a native versioned product dialog. The populated UIA gate verifies the dialog identity, version text, and close behavior | Validated | +| Update command | Check for Updates, disabled while checking/installing | Reachable from Help, immediately reports checking state, and is disabled while a check is active. In-app installation remains incomplete | Partial | +| Tray lifecycle | Restore and exit without foreground daemon window | `TrayLive.Tests.ps1` exercises the physical icon, Open, close-to-hide, single-instance restore, Explorer recovery, popup contents, and visible Exit activation | Validated | +| Connection failure presentation | Explicit visible failure without replacing normal navigation | A persistent inline canvas banner now reports daemon unavailability while leaving sidebar and destination navigation intact; ingress errors take precedence when present. The live UIA gate forces the disconnected state and verifies the dedicated banner text and bounds | Validated | + +## First-run and empty states + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Four-page onboarding | Visual terminology tour, Skip/Back/Continue/Get Started, backend selection, reopen, persisted seen state | Custom rounded Win32 onboarding; all pages exercised and persistence verified | Validated | +| No-project Welcome detail | Graph icon, pitch, explanatory copy, Open Folder action, inline error | Windows matches the centered Graph identity, pitch, explanatory copy, and single Open Folder action. Persistent project-ingress failures now also render as a bounded, wrapped inline canvas alert without replacing navigation; focused geometry coverage and the populated UIA gate verify the alert text and live bounds | Validated | +| Empty global graph | “Nothing running yet”, explanatory copy, Open Folder action, New Loop action | The dedicated overview empty state exposes both bounded Open Folder and New Loop actions; New Loop targets the daemon's `graphcode://global` project. The live UIA gate switches to an empty model, verifies both visible native controls, invokes New Loop, and observes the node form | Validated | +| Empty project canvas | Project-specific empty message and New Loop action | The dedicated “No loops yet” project state exposes its visible New Loop action. The live UIA gate installs an empty local project, invokes that exact command, and observes the project-scoped node form | Validated | +| Empty Quick Chats canvas | Explanation of Quick Chats and New Chat action | Live walkthrough verified the dedicated explanation and New Chat action with corrected non-overlapping layout | Validated | + +## Sidebar + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Graph row | Pinned global graph row with graph glyph | The sidebar now keeps a dedicated Graph destination visible even with no open project, labels it with a graph identity glyph, and routes it through the existing global-overview hit target and UIA destination | Validated | +| Quick Chats group | Selectable header, hover New Chat, disclosure, child rows | The native header remains selectable, reveals a hover-only New Chat action and disclosure, and exposes stable selectable child rows with Rename/Delete context actions. Focused menu tests cover stable chat identity; the live UIA gate invokes New Chat, collapses and restores children, and verifies child runtime identity survives. | Validated | +| Local/remote sections | Group labels, independent collapse, folder/network glyphs | LOCAL and REMOTE retain local/folder and remote/network identity and now toggle independently as native section actions. Focused layout coverage validates mixed ordering, and the live UIA gate collapses LOCAL while proving the REMOTE row and its stable automation identity remain present before restoring LOCAL. | Validated | +| Project rows | Selection, folder type, hover New Loop, disclosure | Open project rows retain selection and local/remote glyphs, reveal hover-only New Loop and disclosure controls, and collapse/restore their own loop tree without changing row identity. The live UIA gate invokes the project-row New Loop action into the real native node form and exercises project collapse/expand through stable UIA actions. | Validated | +| Nested loop tree | Edge-derived hierarchy, persisted expansion, drag reorder of roots | Handoff edges derive a cycle-safe root/descendant tree; nested rows disclose and collapse by stable node ID, and expanded IDs persist atomically in the GraphCode support directory. Focused tests cover collapsed visibility, non-hierarchical message/spawn edges, and state round-trip; the live UIA gate expands a real nested fixture and verifies the child remains expanded after process restart. Root drag reorder is still absent: Windows has no persisted/sidebar-order command or safe reorder transaction, so this row remains incomplete. | Partial | +| Loop row presentation | Type stripe, title, elapsed time, state indicator | Rows now show a loop-type stripe, title, and compact state indicator using the same semantic colors as workspace chrome. Elapsed time remains absent | Partial | +| Project context menu | Move, worktrees, settings, Explorer, remote info, close, remove, delete loops/project | Recent and open sidebar project rows now expose Open, New Loop, Worktrees, Project Settings, Explorer or remote connection information, Close, Remove, and Delete All Loops actions. Move and filesystem Trash remain absent | Partial | +| Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. Final live menu and accessibility evidence remains incomplete | Partial | +| Recent projects | Reachable from Add Folder menu | Recent rows are shown directly under “Projects”, without Add Folder grouping or recent/open distinction | Partial | +| Add Folder menu | Open Folder, Clone, Add Remote, recents | The restored File menu visibly exposes Open Folder, Clone Repository, and Add Remote Repository with shortcuts; a recent-folder submenu remains absent | Partial | +| Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. A deterministic live fixture captured the banner and verified the click raises `GraphCode Update Available`; the offer still hands installation off to the verified release page | Partial | +| Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, and a deterministic live fixture verifies it stacks below the update offer; long-message wrapping and dedicated UIA semantics remain incomplete | Partial | +| Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now show title plus project context (or compact state fallback) with semantic attention coloring. Selection, explicit reason copy, and Stop context action remain incomplete | Partial | +| Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | The optional bottom strip now shows a recent-event count and state-colored event cards with compact state detail. Filtering, timestamps, scrolling, and click navigation remain incomplete | Partial | + +## Graph overview and project canvas + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Cross-project global graph | Every open folder as a lane on one canvas | Windows renders every loaded graph summary as a lane; the real executable was exercised against the protocol stub and multi-project identity/layout has automated coverage | Partial | +| Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Project-captioned bands and loop cards exist; worktree chips and lane actions remain absent | Partial | +| Notebook grid | Grid pans and zooms with canvas | GDI grid pans and zooms with the same transform used by project, overview, and Quick Chats content | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered wheel zoom exist; no pinch/trackpad gesture evidence | Partial | +| Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 are represented in the View menu, and the live UIA provider exposes invokable controls with bounds. The real Quick Chats canvas was exercised from 100% to 110%; hover help and trackpad pinch evidence remain absent | Partial | +| New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | +| Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | +| Canvas attention rail | Count/oldest context and Review action | Painted count banner with shortcut text; no click action or oldest age | Partial | +| Node positioning | Persisted positions and direct card movement where supported | Project cards can be dragged directly, with movement transformed correctly at non-default zoom, shared geometry/hit testing updated during the drag, and capture-loss cancellation restoring the prior position. Offsets are keyed to stable node identity, remapped across daemon reorder, and atomically persisted under the configured GraphCode support directory. Focused reorder/reload regressions and a real physical drag capture validate the complete flow | Validated | +| Connector handles | Hover handles and drag-to-connect | Always-hit-testable right edge supports drag; no visible hover handles or parent-create affordance | Partial | +| Loop card identity | Loop-type stripe, title, state pill, entry/cycle role | Stripe is state-colored rather than type-colored; title/state text and START label only | Partial | +| Loop card live detail | Goal/prompt/check line, progress, metric change, elapsed/backend/model/worktree metadata | Cards now prioritize goal, trigger, or check detail, retain current activity, and show model/worktree or metric metadata in compact secondary lines. Focused tests and a real goal-loop fixture validate the richer card; measured progress/change, elapsed time, backend identity, and token usage remain incomplete | Partial | +| Loop card attention | Reason-aware amber presentation and primary action | NEEDS YOU label exists; no actionable button or reason-specific presentation | Partial | +| Unwired card recovery | Explanation, Wire it up, Mark as entry | Cards with no inbound or outbound edge now show an explicit UNWIRED warning and recovery explanation. Their native context menu exposes Wire it up, which enters the existing drag-to-connect flow, and Mark as entry, which changes the card to START for the session. Focused role/action tests plus live menu and post-action captures validate the flow | Validated | +| Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe resolved cards with a matching landed, clean, pushed worktree now expose separate Reclaim and Keep targets. Reclaim revalidates safety, names the worktree in a fail-closed confirmation, and uses the verified removal path; Keep suppresses the offer for the session. Focused geometry/safety tests and a real resolved-card fixture validate the offer; dedicated UIA descendants remain incomplete | Partial | +| 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 | Project edges now use kind-specific solid/dotted/dashed styling and color, show condition plus fired count in a visible label, and retain selected-edge emphasis. Focused tests and a real-executable fired-message fixture verify the presentation; full cycle-guard wording and collision-free label layout for dense graphs remain incomplete | Partial | +| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form now provides title-plus-ID endpoint selectors for generic creation, locked identities for drag/edit flows, kind/condition/transform controls, conditional transform/spawn fields, cycle guards, inline validation, keyboard traversal, and scrolling. Focused tests and a real-executable capture validate the form; teaching polish and macOS visual treatment remain different | Partial | +| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form now hides internal metadata, provides loop-type/backend/model choices, type-specific fields, explanatory copy, descriptive checkbox accessibility, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. Focused tests and a real-executable capture validate the form; teaching tiles, branch picker, recap, and macOS visual treatment remain incomplete | Partial | +| Node update/rename | Dedicated rename prompt and safe typed updates | Rename now uses a dedicated single-title prompt, trims input, rejects empty titles, re-resolves stable node identity after the modal, and sends the authoritative rename command. A purpose-built typed update editor is still absent | 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 | Background offers Create Edge only; node menu adds non-macOS Message/Memo and omits composite/open-group actions | Partial | + +## Quick Chats + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Quick Chats canvas | Band, cards, pan/zoom, add button, empty state | The real executable was exercised with two deterministic chats. The band, transformed cards, top-right New Chat action, bottom-right zoom controls, and 100% → 110% zoom transition were captured live. Context actions are wired, and the populated live UIA gate validates named, bounded, invokable Quick Chat cards | Partial | +| Quick Chat cards | Title, chat identity, optional backend badge, open/rename/delete menu | A populated live fixture verified title/default-chat identity/optional backend rendering and direct opening (`openQuickChat` was observed by the protocol stub). Cards now expose Open Chat, Rename, and Delete Chat context actions | Validated | +| Create chat | Visible New Chat controls | Empty and populated Quick Chats canvases now expose the New Chat action in the macOS placements; empty-state invocation is live-validated | Partial | +| Rename chat | Single title prompt from row/card | Uses a dedicated single-title modal from the card/keyboard action, trims input, and rejects empty titles | Validated | +| Delete chat | Named confirmation explaining session/scrollback deletion | Uses a named warning that explains terminal-session and scrollback removal, defaults to cancellation, and only sends deletion after confirmation | Validated | +| Chat workspace | Opens a persistent terminal workspace | Session opens in terminal panel | Partial | + +## Loop terminal workspace + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Workspace detail screen | Selected loop replaces canvas detail while sidebar remains | Selecting a sidebar or overview loop now replaces the canvas detail with the full terminal workspace while retaining the sidebar; live stub walkthrough verified the transition and Show in Graph return path | Partial | +| Folder toolbar identity | Project name and local/remote identity | The workspace header now replaces the generic product title with the selected project name and a Local folder/Remote identity; live fixture capture verifies the native rendering | Partial | +| Loop bar | Type stripe, title/state pill, live goal, pass trend, elapsed/usage, Stop, Show in graph | A native 46px workspace band now shows loop-type stripe, title, state, current activity, Stop for unresolved loops, and Show in graph. It reserves terminal geometry, remains visible when terminal initialization fails, and has focused hit-testing coverage. Pass trend, elapsed/usage, and dedicated UIA elements remain incomplete | Partial | +| Tab pills | Named tabs, selection, state indicator, shortcuts, per-tab close | The native tab strip distinguishes agent, shell, and split tabs, paints selection, and supports menu/keyboard tab navigation. Per-tab state indicators, shortcut hints, and close affordances remain incomplete | Partial | +| Split controls | Visible Split Right, Split Down, New Tab buttons | The terminal tab bar now renders distinct New Tab, Split R, and Split D controls wired to the same persistent workspace actions as the menu/shortcuts; geometry and routing have focused regression coverage | Partial | +| Pane headers | agent/shell identity, backend/shell detail, focused state | Product-owned pane headers now distinguish agent and shell panes, label the zmx session detail, and draw an explicit focused-pane accent. Backend-specific detail and final side-by-side live evidence remain incomplete | Partial | +| Mounted background tabs | Switching preserves live terminal surfaces | Covered by workspace implementation tests | Partial | +| Right loop panel | Minimap, upstream/downstream, fired conditions, metric sparkline, branch/start/usage footer | The full workspace now reserves a native right rail with a selected-loop map, upstream/downstream cards, fired-edge coloring, edge conditions, branch/worktree identity, metric/goal detail, and model tier. Metric sparkline, start time, token usage, collapse control, and dedicated UIA children remain incomplete | Partial | +| Show in Graph | Visible loop-bar and menu action | The restored Loop menu and native loop bar both expose Show in Graph; the live workspace walkthrough verified return to the selected graph card, and focused loop-bar hit testing covers the visible action | Partial | + +## Repository ingress + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Open Folder | Native picker from Welcome and Add Folder menu | Welcome and File menu commands use the Windows folder-only File Open dialog with filesystem/path validation. The live UIA gate invokes the empty-state action, verifies the titled native picker, and cancels it safely | Validated | +| Clone Repository sheet | Repository, location picker, derived folder, branch, depth, progress, inline failure, cancel | A purpose-built native dialog now provides HTTPS repository URL, destination browser, derived repository-folder hint, optional branch/depth, inline validation space, Clone/Cancel defaults, and standard keyboard traversal. Clone progress still appears in the application status rather than inside the sheet | Partial | +| Add Remote Repository sheet | Server/user/port/path, explanation, validation progress, inline selectable error | A purpose-built native SSH dialog now provides host, user, default port, absolute path, explanatory copy, inline validation space, Connect/Cancel defaults, and standard keyboard traversal. Connection validation remains blocking after submission | Partial | +| Remote Connection info | Read-only selectable connection sheet | Remote project context menus expose a dedicated read-only connection-information dialog with the encoded remote project identity and management guidance. The live UIA gate opens the native sheet, verifies both pieces of content, and closes it | Validated | + +## Settings and worktrees + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Product Settings window | Backend, three permission pickers, model picker/auto toggle, activity, briefing, beta with explanatory copy | `WindowsProductSettings.zig` exposes native backend/model/Claude/Copilot/Codex selectors, routing/activity/briefing/beta controls, macOS-equivalent consequence copy, and Save/Cancel. Focused tests cover settings preservation and selector copy; `GRAPHCODE_UIA_GATE` mutation 15 opens the real window against an isolated settings file, verifies every required visible control/explanation, proves control-targeted Return saves while preserving unknown fields, and proves Escape cancels byte-for-byte | Validated | +| Infrastructure diagnostics | If retained, separate advanced surface | Daemon pipe/support-directory overrides are now explicitly labeled “Advanced Connection Settings...” while the normal Settings command opens product settings | Validated | +| Project Settings sheet | Resolve policy radio rows, safety explanation, size/count thresholds, immediate save | A purpose-built native Project Settings sheet now presents Remove/Ask/Keep radio rows with consequences, the safe-tier explanation, positive GB/count notice thresholds, and Done/Cancel keyboard semantics. The expanded policy format remains backward-compatible with the two legacy booleans, saves directly on Done without a separate shortcut, and is covered by policy round-trip tests plus a real-executable capture; per-keystroke immediate persistence remains different | Partial | +| Worktree sweep sheet | Safe/look/in-use grouping, size summaries, default selections, reveal, inline destructive confirmation, recovery note | A dedicated native modal now opens from Worktrees after real inspection, labels Safe / Look Before Removing / In Use rows, preselects only fully safe rows, disables blocked rows, states the safety/reflog contract, and executes a revalidated safe batch removal. Presentation is capped at 20 rows and size totals, inline reveal, and dirty forced-removal confirmation remain incomplete | Partial | +| Worktree notice chip | Threshold-driven titlebar and lane notice | A clickable titlebar chip now reports total or reclaimable worktrees and opens the scoped inspection flow. The real populated fixture validates the titlebar notice; configured size/count thresholds and per-lane notice chips remain incomplete | Partial | + +## Updates and dialogs + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| Available update alert | Install, Release Notes, Later | A successful update check now retains the offered version and authoritative release URL, presents a native available-update alert, and can open the verified GitHub release page for notes/download. Direct in-app Install and separately labeled Later remain incomplete | Partial | +| Install progress | In-window progress indicator | Blocked on a publishable Windows installer asset and integrity/signing metadata. Every current upstream release contains only a macOS DMG (verified through the GitHub releases API on 2026-08-17), so an in-app Windows download cannot yet select or authenticate an installable artifact without inventing an unsafe source | Blocked | +| Relaunch prompt | Relaunch Now/Later and session continuity explanation | Blocked with installation because there is no published Windows artifact to stage or relaunch into. The native tray lifecycle and zmx-backed sessions already preserve daemon/terminal continuity, but the updater cannot truthfully offer Relaunch Now until a signed Windows package exists | Blocked | +| Install failure | Download in Browser/Cancel with reason | The Windows flow deliberately hands off to the verified browser download and reports browser-launch failure, but it does not yet attempt an in-app install first | Partial | +| Loop rename | Title field, Return submits, explanatory text | The dedicated single-title modal explains where the title appears, prepopulates the current value, trims and validates submission, and re-resolves the stable loop ID after the modal. The populated UIA gate edits the native field and verifies Return submits and closes the dialog | Validated | +| Loop delete | Named loop and full consequence message | Names the loop, explains graph-connection removal, and defaults to cancellation | Validated | +| Chat rename/delete | Dedicated prompts | Dedicated single-title rename modal and named fail-closed deletion warning are wired from card actions and shortcuts | Validated | +| Project delete loops | Dedicated confirmation | Sidebar project menus expose Delete All Loops through one fail-closed implementation with graph and filesystem consequence copy, safe cancellation default, and the dedicated daemon command. The live UIA gate verifies the native confirmation and cancellation path | Validated | +| Project remove/trash | Distinct reversible remove and filesystem Trash choices | Remove from GraphCode is now distinct, confirmed, and explicitly preserves files. A separate filesystem Trash action remains absent | Partial | + +## Accessibility, input, and visual behavior + +| macOS surface | Required visible behavior | Windows evidence | Status | +|---|---|---|---| +| UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. Daemon-to-model UIA integration, embedded terminal text providers, and several remaining dialogs still need end-to-end evidence | Partial | +| Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. Some context-only actions and canvas gestures still lack visible hints | Partial | +| IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial | +| Clipboard/selection | Terminal copy/paste and mouse selection | Winghostty terminal gates cover core behavior | Partial | +| Per-monitor DPI | Layout and controls scale correctly across monitors | No complete live multi-DPI walkthrough recorded | Partial | +| Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; several legacy graph/repository forms still use default Win32 controls | Partial | + +## Audit conclusion + +The Windows branch has substantial protocol, lifecycle, persistence, terminal, graph +mutation, tray, and packaging behavior, but it does **not** currently have complete UI +or screen parity. The previous parity statement conflated backend reachability with +user-visible parity. The largest corrective work is: + +1. Restore and complete the application menu and navigation state model. +2. Implement the sidebar, global graph, Quick Chats canvas, project canvas chrome, and + loop workspace as distinct application-owned surfaces. +3. Replace raw protocol forms with structured node, edge, settings, repository, and + worktree screens. +4. Implement the missing update, project-management, rename/delete, empty, and + connection-info states. +5. Expand UI Automation and live walkthrough coverage to every row above before any + complete-parity claim. diff --git a/investigation/visual-baseline/README.md b/investigation/visual-baseline/README.md new file mode 100644 index 00000000..0d71bb33 --- /dev/null +++ b/investigation/visual-baseline/README.md @@ -0,0 +1,14 @@ +# Windows visual baseline + +This fixture set is a public-source contract for the Windows shell. It freezes the +GraphCode-owned geometry, state words, colors, IDs, timestamps, metrics, and static +terminal text needed for deterministic review without building the Windows UI. + +`manifest.json` cites the existing GraphCode screenshot, `Theme.swift`, card +presentation, canvas, sidebar, workspace, and parity sources. The four DPI entries are +layout variants, not screenshots tied to a particular machine. + +The GraphCode-owned regions are safe for screenshot comparison. Terminal rendering, +input, IME, clipboard, resize, and accessibility remain live Winghostty functional +tests; the text files in `fixtures` are only stable placeholders for testing workspace +layout and split ownership. diff --git a/investigation/visual-baseline/fixtures/terminal-agent.txt b/investigation/visual-baseline/fixtures/terminal-agent.txt new file mode 100644 index 00000000..8e288d38 --- /dev/null +++ b/investigation/visual-baseline/fixtures/terminal-agent.txt @@ -0,0 +1,5 @@ +GraphCode visual baseline +session: fixture-agent +mode: interactive +pass 4 · editing fixture metrics +awaiting deterministic reply diff --git a/investigation/visual-baseline/fixtures/terminal-log.txt b/investigation/visual-baseline/fixtures/terminal-log.txt new file mode 100644 index 00000000..099c75dc --- /dev/null +++ b/investigation/visual-baseline/fixtures/terminal-log.txt @@ -0,0 +1,4 @@ +GraphCode visual baseline +event stream: fixed +14:55 metric sample recorded +15:00 workspace attached diff --git a/investigation/visual-baseline/fixtures/terminal-shell.txt b/investigation/visual-baseline/fixtures/terminal-shell.txt new file mode 100644 index 00000000..98e88d43 --- /dev/null +++ b/investigation/visual-baseline/fixtures/terminal-shell.txt @@ -0,0 +1,5 @@ +GraphCode visual baseline +shell: fixture-shell +branch: baseline +$ graphcode status --fixture +9 loops · 4 need attention diff --git a/investigation/visual-baseline/manifest.json b/investigation/visual-baseline/manifest.json new file mode 100644 index 00000000..c7e7b962 --- /dev/null +++ b/investigation/visual-baseline/manifest.json @@ -0,0 +1,402 @@ +{ + "schemaVersion": 1, + "id": "graphcode.windows.visual-baseline", + "baseCommit": "ece55b6", + "clock": "2026-01-15T15:00:00Z", + "screenshotSources": [ + { + "path": "screenshots/graph-hero.png", + "sha256": "5b97274a9686b663cd07d5ab01a9e531ed3ee2bc53e6fc0f66fbc2ec7016a4f5" + } + ], + "sourceReferences": [ + "graphcode/Sources/Features/App/Theme.swift", + "graphcode/Sources/Features/Canvas/LoopCardView.swift", + "graphcode/Sources/Features/Canvas/LoopCardPresentation.swift", + "graphcode/Sources/Features/App/AppSidebarView.swift", + "graphcode/Sources/Features/Project/ProjectCanvasView.swift", + "graphcode/Sources/Features/Canvas/CanvasAttentionRail.swift", + "graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift", + "graphcode/Sources/Features/LoopWorkspace/LoopWorkspacePanes.swift", + "graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift", + "graphcode/Sources/Features/App/ProjectHeader.swift", + "investigation/ui-parity-matrix.md" + ], + "tokenContracts": [ + { "name": "Theme.windowTone", "value": "#1E1E1E" }, + { "name": "Theme.windowBackground", "value": "opacity(0.55)" }, + { "name": "Theme.canvasBackground", "value": "opacity(0.62)" }, + { "name": "Theme.canvasTone", "value": "#181818" }, + { "name": "Theme.canvasGridLine", "value": "#272727" }, + { "name": "Theme.unfocusedPaneVeil", "value": "opacity(0.35)" }, + { "name": "Theme.terminalBackgroundOpacity", "value": "0.80" }, + { "name": "Theme.workspaceRail", "value": "#1D1D21" }, + { "name": "Theme.paneFocusTint", "value": "#0A84FF" }, + { "name": "LoopCardView.Metrics.size", "value": "250x106" }, + { "name": "LoopCardView.Metrics.radius", "value": "11" }, + { "name": "LoopCardView.Metrics.stripe", "value": "4" }, + { "name": "LoopWorkspaceRail.width", "value": "212" }, + { "name": "PaneHeaderView.height", "value": "22" }, + { "name": "CanvasAttentionRail.reviewShortcut", "value": "⌘⇧R" } + ], + "graph": { + "id": "00000000-0000-4000-8000-000000000001", + "project": { + "id": "project-local", + "name": "Windows visual baseline", + "path": "graphcode://fixtures/windows-visual-baseline", + "remote": false + }, + "nodes": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "title": "Metric gate", + "type": "goalBased", + "state": "running", + "displayWord": "RUNNING", + "createdAt": "2026-01-15T13:00:00Z", + "activity": "editing fixture metrics", + "metricDirection": "minimize", + "metricHistory": [ + { "recordedAt": "2026-01-15T14:30:00Z", "value": 1.42 }, + { "recordedAt": "2026-01-15T14:55:00Z", "value": 1.1 } + ], + "position": { "x": 180, "y": 180 } + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Human review", + "type": "turnBased", + "state": "awaitingInput", + "displayWord": "NEEDS YOU", + "createdAt": "2026-01-15T14:20:00Z", + "activity": "awaiting a fixture reply", + "metricHistory": [], + "position": { "x": 520, "y": 180 } + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "title": "Stranded handoff", + "type": "goalBased", + "state": "blocked", + "displayWord": "BLOCKED", + "createdAt": "2026-01-15T14:40:00Z", + "activity": "upstream failed", + "metricDirection": "maximize", + "metricHistory": [ + { "recordedAt": "2026-01-15T14:40:00Z", "value": 41 }, + { "recordedAt": "2026-01-15T14:50:00Z", "value": 41 } + ], + "position": { "x": 860, "y": 180 } + }, + { + "id": "44444444-4444-4444-8444-444444444444", + "title": "Pipeline complete", + "type": "composite", + "state": "succeeded", + "displayWord": "DONE", + "createdAt": "2026-01-15T12:30:00Z", + "activity": "sub-graph resolved", + "metricHistory": [], + "position": { "x": 180, "y": 420 } + }, + { + "id": "55555555-5555-4555-8555-555555555555", + "title": "Failed check", + "type": "timeBased", + "state": "failed", + "displayWord": "FAILED", + "createdAt": "2026-01-15T13:30:00Z", + "activity": "fixture command exited 1", + "metricDirection": "minimize", + "metricHistory": [ + { "recordedAt": "2026-01-15T14:00:00Z", "value": 3 }, + { "recordedAt": "2026-01-15T14:30:00Z", "value": 5 } + ], + "position": { "x": 520, "y": 420 } + }, + { + "id": "66666666-6666-4666-8666-666666666666", + "title": "Stalled worker", + "type": "turnBased", + "state": "stalled", + "displayWord": "STALLED", + "createdAt": "2026-01-15T14:00:00Z", + "activity": "no progress in fixture window", + "metricHistory": [], + "position": { "x": 860, "y": 420 } + }, + { + "id": "77777777-7777-4777-8777-777777777777", + "title": "Scheduled poll", + "type": "timeBased", + "state": "idle", + "displayWord": "SCHEDULED", + "createdAt": "2026-01-15T14:10:00Z", + "activity": "next fixture tick", + "metricDirection": "maximize", + "metricHistory": [ + { "recordedAt": "2026-01-15T14:40:00Z", "value": 0.5 }, + { "recordedAt": "2026-01-15T14:55:00Z", "value": 0.75 } + ], + "position": { "x": 180, "y": 660 } + }, + { + "id": "88888888-8888-4888-8888-888888888888", + "title": "Stopped loop", + "type": "goalBased", + "state": "stopped", + "displayWord": "STOPPED", + "createdAt": "2026-01-15T11:00:00Z", + "activity": "stopped by fixture operator", + "metricHistory": [], + "position": { "x": 520, "y": 660 } + }, + { + "id": "99999999-9999-4999-8999-999999999999", + "title": "Downstream wait", + "type": "composite", + "state": "waiting", + "displayWord": "WAITING", + "createdAt": "2026-01-15T12:00:00Z", + "activity": "downstream fixture loops active", + "metricHistory": [], + "position": { "x": 860, "y": 660 } + } + ], + "edges": [ + { + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "from": "11111111-1111-4111-8111-111111111111", + "to": "22222222-2222-4222-8222-222222222222", + "kind": "handoff", + "condition": "success", + "fired": false + }, + { + "id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "from": "55555555-5555-4555-8555-555555555555", + "to": "33333333-3333-4333-8333-333333333333", + "kind": "handoff", + "condition": "success", + "fired": false + }, + { + "id": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "from": "44444444-4444-4444-8444-444444444444", + "to": "99999999-9999-4999-8999-999999999999", + "kind": "spawn", + "condition": "success", + "fired": true + } + ] + }, + "sidebar": { + "sections": [ "Local Projects", "Remote Repositories" ], + "rows": [ + { "id": "graph", "kind": "graph", "title": "Graph", "expanded": true }, + { + "id": "project-local", + "kind": "project", + "title": "Windows visual baseline", + "remote": false, + "expanded": true + }, + { + "id": "project-remote", + "kind": "project", + "title": "Remote fixture", + "remote": true, + "path": "ssh://fixture.example/graphcode/visual-baseline", + "expanded": true + }, + { + "id": "remote-loop", + "kind": "node", + "title": "Remote review", + "state": "running", + "remote": true + } + ], + "remoteIndicator": { + "glyph": "network", + "label": "remote", + "source": "graphcode/Sources/Features/App/ProjectHeader.swift" + } + }, + "canvas": { + "grid": { "cellSize": 24, "line": "Theme.canvasGridLine" }, + "viewport": { "width": 1280, "height": 820 }, + "zoom": 1.0, + "attentionRail": { + "count": 4, + "oldestNodeID": "55555555-5555-4555-8555-555555555555", + "oldestAge": "1h 30m", + "reviewShortcut": "⌘⇧R", + "items": [ + { "nodeID": "55555555-5555-4555-8555-555555555555", "reason": "failed" }, + { "nodeID": "66666666-6666-4666-8666-666666666666", "reason": "stalled" }, + { "nodeID": "22222222-2222-4222-8222-222222222222", "reason": "awaitingInput" }, + { "nodeID": "33333333-3333-4333-8333-333333333333", "reason": "blocked" } + ] + } + }, + "workspace": { + "loopID": "22222222-2222-4222-8222-222222222222", + "projectPath": "ssh://fixture.example/graphcode/visual-baseline", + "remote": true, + "focusedPaneID": "pane-agent", + "rail": { + "visible": true, + "width": 212, + "downstreamCount": 1 + }, + "tabs": [ + { + "id": "tab-goal", + "title": "Goal", + "selected": true, + "root": { + "kind": "split", + "direction": "horizontal", + "children": [ + { + "kind": "leaf", + "paneID": "pane-agent", + "title": "agent", + "detail": "claude", + "focused": true, + "snapshot": "investigation/visual-baseline/fixtures/terminal-agent.txt" + }, + { + "kind": "split", + "direction": "vertical", + "children": [ + { + "kind": "leaf", + "paneID": "pane-shell", + "title": "shell", + "detail": "zsh", + "focused": false, + "snapshot": "investigation/visual-baseline/fixtures/terminal-shell.txt" + }, + { + "kind": "leaf", + "paneID": "pane-log", + "title": "shell", + "detail": "zsh", + "focused": false, + "snapshot": "investigation/visual-baseline/fixtures/terminal-log.txt" + } + ] + } + ] + } + }, + { + "id": "tab-shell", + "title": "Shell", + "selected": false, + "root": { + "kind": "leaf", + "paneID": "pane-extra", + "title": "shell", + "detail": "zsh", + "focused": false, + "snapshot": "investigation/visual-baseline/fixtures/terminal-shell.txt" + } + } + ] + }, + "dpiVariants": [ + { "id": "100", "scale": 1.0, "viewport": { "width": 1600, "height": 1000 } }, + { "id": "125", "scale": 1.25, "viewport": { "width": 1280, "height": 800 } }, + { "id": "150", "scale": 1.5, "viewport": { "width": 1066, "height": 666 } }, + { "id": "200", "scale": 2.0, "viewport": { "width": 800, "height": 500 } } + ], + "regions": [ + { + "id": "sidebar", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/App/AppSidebarView.swift" + }, + { + "id": "graph-canvas", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/Project/ProjectCanvasView.swift" + }, + { + "id": "attention-rail", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/Canvas/CanvasAttentionRail.swift" + }, + { + "id": "remote-indicator", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/App/ProjectHeader.swift" + }, + { + "id": "terminal-workspace-chrome", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift" + }, + { + "id": "terminal-split-layout", + "owner": "GraphCode", + "kind": "deterministic", + "source": "graphcode/Sources/Features/LoopWorkspace/LoopWorkspacePanes.swift" + }, + { + "id": "winghostty-terminal-surface", + "owner": "Winghostty", + "kind": "live-functional", + "source": "investigation/contracts/winghostty-host.md" + }, + { + "id": "winghostty-input-dpi", + "owner": "Winghostty", + "kind": "live-functional", + "source": "investigation/contracts/winghostty-host.md" + } + ], + "renderingBoundary": { + "deterministicScreenshotRegions": [ + "sidebar", + "graph-canvas", + "attention-rail", + "remote-indicator", + "terminal-workspace-chrome", + "terminal-split-layout" + ], + "liveWinghosttyFunctionalTests": [ + "winghostty-terminal-surface", + "winghostty-input-dpi" + ], + "rule": "Compare only GraphCode-owned regions in deterministic screenshots; exercise Winghostty surfaces live." + }, + "terminalSnapshots": [ + { + "id": "agent", + "path": "investigation/visual-baseline/fixtures/terminal-agent.txt", + "owner": "GraphCode", + "kind": "deterministic-layout-fixture" + }, + { + "id": "shell", + "path": "investigation/visual-baseline/fixtures/terminal-shell.txt", + "owner": "GraphCode", + "kind": "deterministic-layout-fixture" + }, + { + "id": "log", + "path": "investigation/visual-baseline/fixtures/terminal-log.txt", + "owner": "GraphCode", + "kind": "deterministic-layout-fixture" + } + ] +} diff --git a/investigation/windows-implementation-plan.md b/investigation/windows-implementation-plan.md new file mode 100644 index 00000000..d26fe123 --- /dev/null +++ b/investigation/windows-implementation-plan.md @@ -0,0 +1,275 @@ +# GraphCode Windows implementation plan + +Durable tracking: + +- GraphCode meta issue: https://github.com/scgopi/GraphCode/issues/89 +- Winghostty provider: https://github.com/coneilen/winghostty/issues/1 +- zmx provider: https://github.com/coneilen/zmx/issues/1 + +This document is the repository copy of the approved multi-session plan. GitHub issues, +provider commits, and the integration commit stack are the cross-session source of truth. +Session-local SQL todos mirror the IDs below and can be reconstructed from this document. + +## Approach + +- Keep GraphcodeKit, orchestration, persistence, backend policy, and resume behavior in Swift. +- Build Windows `graphcoded.exe` and `graphcode.exe` around secure Named Pipes. +- Port real zmx protocol/CLI semantics to ConPTY and Named Pipes. +- Extract a complete embeddable `win32_host` Zig package from a maintained Winghostty fork. +- Build a native Zig/Win32 GraphCode shell that owns product UI and workspace layout. +- Support existing POSIX remote hosts in the first Windows release through an authenticated + SSH-only loopback TCP bridge to the local Named Pipe daemon. +- Preserve GraphCode's visual identity with native Windows controls and accessibility. + +## Red/green/refactor rule + +Every feature, extraction, protocol change, security behavior, UI flow, packaging change, +and bug fix follows: + +1. **RED:** add the focused automated test/contract first and observe the intended failure. +2. **GREEN:** implement the smallest correct behavior and pass the same command. +3. **REFACTOR:** improve structure while the focused and adjacent regression tests stay green. + +PRs record: + +```text +RED: -> +GREEN: -> pass +REGRESSION: -> pass +``` + +Deliberately failing commits are not integrated. Green test and implementation land +together in a bisectable, DCO-signed commit. + +## Worktree and integration model + +- The Windows integration branch is the only GraphCode aggregation branch. +- Every task uses a dedicated worktree and branch. +- A task records todo ID, repository, owner, base SHA, owned files, RED/GREEN evidence, + regression command, provider pin, PR, integration commit, blockers, and next dependency + in GraphCode issue #89. +- After green validation and review, commit with `git commit -s`, rebase the committed + branch onto the required integration SHA, rerun tests, and integrate with + `git cherry-pick -x`. +- No two fleet agents own overlapping files or contracts. +- Provider changes land and are pinned before GraphCode consumer commits. + +## Provider repositories + +### Winghostty + +Repository: `coneilen/winghostty` + +Extract `win32_host` so: + +- GraphCode supplies the parent HWND and owns the sole message loop; +- the host owns terminal child windows, renderer, input, IME, clipboard, DPI, and UIA; +- configuration is copied at creation; +- teardown guarantees no later callbacks or renderer/process access; +- original Winghostty behavior remains green. + +### zmx + +Repository: `coneilen/zmx` + +- Rebase GraphCode's non-leader mouse behavior onto current upstream. +- Add platform interfaces before Windows implementations. +- Preserve the real zmx header, tags, CLI, labels, long-lived attach stream, terminal-state + reconstruction, resize, detach/reconnect, error semantics, and tested multi-attach policy. +- A custom proof protocol or raw-output snapshot is not acceptance. + +## Cross-platform contracts + +Freeze serially before implementation fleets: + +- `PlatformPaths` +- `ProcessRunner` +- `ShellStrategy` +- `ByteStream` +- `DaemonConnection` / `DaemonListener` +- dual-stack daemon protocol +- `SessionService` +- `StartupManager` +- `RemoteBridge` +- Winghostty host ownership contract +- zmx platform interfaces + +The daemon accepts deployed protocol-v1 app/CLI/shim clients unchanged. Protocol v2 begins +only after negotiation and adds request IDs, typed responses/errors, event sequencing, +subscriptions, reconnect, and replay policy. + +## Remote SSH contract + +The first Windows release supports existing POSIX remote hosts: + +- local GraphCode continues using Named Pipes; +- one per-host bridge listens on ephemeral `127.0.0.1`; +- SSH reverse forwarding binds a remote-loopback TCP port; +- a versioned `0600` bridge-state record contains instance/generation, port, capability, + rotation data, and protocol version; +- the one-shot Python shim reads that record for every command; +- strict host-key checking, forward-failure detection, effective loopback-bind verification, + token rotation, stale cleanup, and explicit diagnostics are required; +- macOS retains Unix-socket forwarding. + +## Visual contract + +Preserve: + +- neutral near-black canvas/window tones and subtle graph-paper grid; +- dark glossy terminal chrome; +- rounded loop cards with type accent stripe and entry port; +- redundant state color/shape/text; +- monospaced live/meta text; +- orange needs-you warmth, glow, border, and review rail; +- focused split ring and unfocused pane veil; +- terminal-first information density. + +Use deterministic graphs, IDs, event streams, state, metrics, clock, and static terminal +snapshots for image fixtures. Test live Winghostty surfaces functionally. Native titlebar, +menus, dialogs, focus cues, and keyboard conventions may adapt to Windows. + +## Durable todo registry + +| ID | Deliverable | +|---|---| +| `win-bootstrap` | DCO, validation matrix, forks, issues, worktree conventions | +| `win-tdd-harness` | TDD evidence, fixtures, CI, committed plan | +| `win-contracts` | Frozen platform/protocol/provider/remote contracts | +| `win-wing-baseline` | Reproducible Winghostty baseline | +| `win-zmx-rebase` | Current zmx plus GraphCode mouse behavior | +| `win-visual-baseline` | Deterministic visual fixtures | +| `win-swift-platform` | Shared Swift package, paths, process, shell | +| `win-protocol-dualstack` | v1 compatibility and v2 correlation/subscriptions | +| `win-wing-host-api` | Embeddable Winghostty host API | +| `win-zmx-platform` | zmx OS interfaces | +| `win-remote-spike` | Restart-safe authenticated bridge proof | +| `win-daemon-pipe` | Windows daemon and CLI | +| `win-wing-renderer` | Terminal renderer extraction | +| `win-wing-input` | Input/IME/clipboard extraction | +| `win-wing-access` | DPI/UIA extraction | +| `win-wing-examples` | External host/lifecycle tests | +| `win-zmx-conpty` | ConPTY/process backend | +| `win-zmx-ipc` | Named Pipe backend | +| `win-zmx-attach` | Real attach/VT/multi-attach | +| `win-zmx-agent` | Coding-agent compatibility | +| `win-remote-bridge` | Production SSH bridge/shim | +| `win-terminal-gate` | Two persistent rendered terminals | +| `win-shell-scaffold` | Native shell foundation | +| `win-sidebar` | Sidebar/global overview | +| `win-canvas` | Project graph canvas | +| `win-workspace` | Terminal tabs/splits/focus | +| `win-forms` | Forms/settings/navigation | +| `win-attention-worktree` | Attention/worktree flows | +| `win-ui-integrate` | Integrated visual/accessibility UI | +| `win-remote-e2e` | POSIX remote parity | +| `win-packaging` | Signed installer/runtimes | +| `win-hardening` | Performance/security/hardware/regressions | +| `win-final-pr` | Final reviewed GraphCode PR | + +## Execution sequence + +### Serial bootstrap + +1. Correct DCO history. +2. Land runnable validation matrix. +3. Land TDD harness and this committed plan. +4. Land serial contracts and record `CONTRACT_BASE`. + +### Provider baseline fleet + +- Winghostty baseline/CI/dependency graph. +- zmx upstream rebase and GraphCode mouse patch. +- deterministic GraphCode visual fixtures. + +### Foundation fleet + +- shared Swift platform services; +- dual-stack daemon protocol; +- Winghostty host API skeleton; +- zmx platform interfaces/real Windows protocol build; +- remote bridge state/security spike. + +Integrate serially and publish `FOUNDATION_BASE`. + +### Platform fleet + +- Windows daemon/CLI; +- Winghostty renderer, input/IME/clipboard, DPI/UIA; +- zmx ConPTY and Named Pipes; +- production remote bridge. + +Provider gates precede GraphCode pins. Publish `TERMINAL_BASE` only after the external +two-surface and real zmx attach tests pass. + +### Shell and UI fleets + +- terminal architecture gate; +- Windows shell scaffold; +- sidebar/overview; +- graph canvas/cards/edges; +- terminal workspace; +- forms/settings/navigation; +- attention/worktree flows; +- visual/accessibility integration. + +### Remote, packaging, and hardening + +- POSIX remote-host end-to-end parity; +- Windows CI, installer, signing, runtime/license packaging; +- session/crash/performance, GPU, DPI, IME, screen reader, security, path, remote, and + macOS regression matrices. + +The final hardening gate is executable rather than checklist-only. Its mandatory +local fixtures cover 4 MiB backpressure, 3-second lifetime, crash/restart, +four concurrent terminals, Unicode clipboard/path handling, and process cleanup +with explicit 10/15-second ceilings. Environment-only GPU/WGL, physical +display/DPI, UIA/screen-reader, ACL/login/reboot, authenticated SSH, and real +provider reconnect tests are isolated behind an explicit target gate and fail +when selected without a target. The authoritative macOS/shared-Swift workflow is +`.github/workflows/macos-shared-regression.yml`; Windows validation does not claim +to execute macOS. + +## Bug filing + +For a pre-existing GraphCode bug: + +1. reproduce on unchanged public `main`; +2. add a regression test and observe RED; +3. search existing issues; +4. file immediately with sanitized evidence and the RED command/failure; +5. link it from the task/PR ledger; +6. use a dedicated fix worktree if it blocks the port. + +Port-introduced bugs stay in the owning worktree. Provider bugs are filed only when +reproducible against their public repositories. + +## Release gate + +- local and POSIX-remote sessions remain persistent, attachable, and steerable; +- two or more complete rendered terminal surfaces coexist reliably; +- graph/sidebar/workspace behavior and visual identity meet the parity contract; +- keyboard, DPI, IME, clipboard, UIA, installer, and security gates pass; +- macOS behavior remains green; +- provider provenance and exact pins are documented; +- the integration branch contains a reviewed, signed, bisectable commit stack ready for + one PR to `main`. +# Packaging status + +The production Windows bundle is implemented by `Tools/windows/package.ps1`. +Before release, build the Swift products and pinned Zig providers into a +release directory, then run the package build, verification, install/upgrade, +and uninstall tests. CI must provide the exact Zig 0.15.2 and 0.16.0 +toolchains plus the accepted Winghostty `host-integration` and zmx `attach` +worktrees. Artifacts are unsigned unless an explicit certificate thumbprint +and optional timestamp URL are supplied; unsigned status is intentionally +visible and is not release-signing evidence. + +## Release acceptance status + +- Windows implementation, UI integration, remote parity, packaging, and hardening are complete. +- `Tools/windows/validate.ps1 -Task all` passes on the integrated branch. +- The final commit stack is reviewed, bisectable, and DCO-signed. +- The unsigned release bundle and a transferable Git bundle are produced with SHA-256 checksums. +- PR publication is blocked only by the available GitHub OAuth credentials lacking `workflow` + scope for the new workflow files. diff --git a/investigation/windows-port-feasibility.md b/investigation/windows-port-feasibility.md new file mode 100644 index 00000000..74873b30 --- /dev/null +++ b/investigation/windows-port-feasibility.md @@ -0,0 +1,200 @@ +# GraphCode Windows port feasibility + +## Executive decision + +**A native Windows port is feasible, but the architecture in the handoff is only partly +proven.** + +- **Go**: shared Swift domain/orchestration, Windows `graphcoded`, CLI, Named Pipe IPC, + and Windows paths/process services. +- **Conditional go**: a cross-platform zmx backend. The OS primitives are feasible, but + the spike does not preserve zmx's real wire protocol, long-lived attach behavior, or + terminal snapshot semantics. +- **Conditional go**: the Windows UI. `libghostty-vt` works and can back multiple custom + Win32 terminal views, but that does not yet prove a complete Ghostty-rendered surface + with input, IME, clipboard, accessibility, DPI, and multi-surface composition. +- **Defer**: remote SSH parity, ARM64, and production installer/updater. + +The recommended program is therefore headless-first. Do not begin the graph editor until +the complete two-terminal Ghostty host gate passes. + +## Evidence produced + +| Spike | Result | Decision impact | +|---|---|---| +| Full GraphcodeKit SwiftPM build | Dependencies build; GraphCode compilation stops at unconditional `import Darwin` in `PTYProcessSession` | Swift dependency graph is viable; platform seams are source-level, not a Swift-on-Windows blocker | +| Portable Swift domain | 31 files compile; graph/settings JSON tests pass | Core domain remains Swift | +| Windows path behavior | Reproduced drive-path rejection, malformed support override, unsafe persistence filename | Paths require an early platform abstraction | +| Swift Named Pipes | Request/response, events, multiple clients, reconnect, unavailable daemon, connection-availability timeout, and oversized-frame rejection pass | `graphcoded` can remain Swift and use WinSDK directly; connected-I/O deadlines remain open | +| Swift `Process` | Direct exe preserves argv/Unicode/cwd/env; `.cmd` works; `.ps1` needs PowerShell host | Foundation `Process` is viable with explicit extension/shell policy | +| zmx ConPTY primitives | ConPTY + Named Pipe + Job Object; short connections, background output, raw-buffer snapshot, stop all pass | OS primitives are feasible; actual zmx protocol/attach parity remains unproven | +| Ghostty VT custom window | `libghostty-vt` builds; two independent child terminal views render in one GraphCode-owned HWND | VT/state embedding is feasible; full Ghostty surface remains a separate gate | + +Spike source and commands are under `investigation/spikes/`. + +## Material corrections to the handoff + +1. **Daemon transport is not isolated to two socket files.** `GraphStore` and + `ProjectRegistry` own raw `Int32` descriptors and write frames directly. +2. **`PTYProcessSession` is not merely an old interactive path.** It runs zmx control + commands, label reads, sends, kills, log probes, and remote commands. Split it into a + pipe-based process runner and zmx's interactive ConPTY backend. +3. **Current path handling is functionally incompatible with Windows.** This affects + project admission, support-directory overrides, and persistence filenames. +4. **Remote support is not a small SSH executable-path change.** It depends on POSIX + shells, AF_UNIX, chmod/shebangs, control sockets, and reverse Unix socket forwarding. +5. **`libghostty-vt` Windows support is not proof of a full reusable Ghostty surface.** + The handoff conflates terminal state APIs with the renderer/application runtime. +6. **The GraphCode zmx fork is stale.** Its mouse-input patch remains relevant but is 26 + upstream commits behind and conflicts when moved to current upstream. +7. **Swift toolchain setup needs to be explicit.** The official 6.3.3 toolkit, runtime + DLL path, Windows SDK root, MSVC libraries, and Git bare-repository policy all affected + the spike. CI must codify this rather than assume `swift` on PATH is sufficient. + +## Recommended architecture + +```text +GraphCode shared Swift package + domain, graph, persistence, backend/session policy + | + graphcoded (Swift) + | + protocol + length framing + / \ + Unix socket/macOS Named Pipe/Windows + | | + SwiftUI/AppKit shell native Windows shell + | | + GhosttyKit full Ghostty host gate + | | + zmx cross-platform zmx + ConPTY +``` + +Required shared interfaces: + +- `DaemonConnection` / `DaemonListener` +- `ByteStream` +- `PlatformPaths` +- `ProcessRunner` +- `ShellStrategy` +- `SessionService` +- `StartupManager` + +Do not put platform conditionals throughout `ZmxSessionLauncher`. Keep backend/resume/ +message policy shared and move command construction/execution behind those services. + +## zmx feasibility + +zmx is portable in architecture but not in implementation. Current Unix dependencies +include `forkpty`, double-fork daemonization, AF_UNIX, `poll`, signals/self-pipe, termios, +ioctl resize, process groups, UID/XDG paths, `/bin/sh`, and Unix quoting. + +The Windows spike demonstrated the enabling OS behavior: + +```text +start ConPTY child +send BEFORE +detach client +send DETACHED while detached +reattach and receive snapshot containing both +stop and clean the process tree +``` + +It did **not** implement zmx's real 8-byte-header/tagged protocol, a long-lived +bidirectional attached client, libghostty-vt reconstruction, resize, or concurrent attach +leadership. Its `attach` is a one-shot raw-buffer snapshot and `detach` records no client +state. + +Recommendation: + +1. Rebase the GraphCode mouse patch onto current upstream zmx. +2. Introduce platform modules for PTY/process, IPC, daemon lifecycle, event wait, paths, + resize/control, and task shell. +3. Preserve the existing 8-byte IPC header, 552-byte info structure, tags, CLI names, and + GraphCode-used commands (`run -d`, `attach`, `send`, `get`, `set`, `kill`). +4. Before committing to the backend port, build a source-integrated zmx prototype that + preserves the real wire ABI/CLI and proves long-lived attach, detach, reconnect with + VT reconstruction, resize, concurrent attach policy, and one real agent TUI. +5. Add black-box compatibility tests before changing GraphCode. + +Confidence is high for the Windows primitives and medium-low for full zmx protocol/task/ +signal/agent parity. + +## Ghostty/Winghostty feasibility + +Positive evidence: + +- `libghostty-vt` builds on Windows. +- Its public C API exposes terminal lifecycle, VT writes, resize, render snapshots, + row/cell iterators, styles/colors/graphemes, key/mouse/focus encoders, selection, and + paste validation. +- Public terminal row/cell state can drive two independent child views in one + GraphCode-owned top-level HWND. +- Multiple terminal states are not inherently a blocker. + +Negative evidence: + +- Upstream `ghostty.h` is explicitly an internal macOS/iOS embedder API and has no HWND + platform payload. +- Upstream Ghostty has no Win32 application runtime or public Windows + `create_surface(parent_hwnd)` equivalent. +- `libghostty-vt` provides no windowing, ConPTY, process launch, GPU context, font shaping, + glyph atlas, compositor, clipboard ownership, or event routing. + +Not yet proven: + +- reuse of Ghostty's production renderer rather than a custom GDI renderer +- complete keyboard layout and IME behavior +- mouse selection and clipboard +- accessibility/UIA +- DPI and teardown under repeated surface recreation +- compositor behavior with graph canvas plus two live terminal surfaces +- a maintainable build against current Winghostty/Ghostty revisions + +Winghostty proves the topology is possible: its internal `Host` owns the top-level HWND +and child `Surface` values own HWND/HDC/HGLRC/CoreSurface instances and WGL rendering. +However, this boundary is internal and tightly coupled across `win32.zig`, `Surface.zig`, +`App.zig`, renderer/OpenGL, compositor, clipboard, UIA, tabs/splits, shell, IPC, recovery, +and settings modules. Winghostty is valuable source evidence, not a safe dependency +decision yet. Its tested build has a Zig-version/path conversion failure, while newer Zig +is API-incompatible. + +**UI gate:** a GraphCode-owned window containing two complete Ghostty-rendered surfaces, +each running a command and independently handling focus/input/resize/clipboard/IME. Until +that passes, “Zig + Win32 using Ghostty/Winghostty” remains a preferred hypothesis. The +two feasible implementation choices are both substantial: + +1. extract/maintain Winghostty's internal Win32/OpenGL runtime; or +2. use `libghostty-vt` and build GraphCode's own production renderer/input stack. + +## Delivery plan and measured estimate + +| Phase | Exit condition | Estimate | +|---|---|---:| +| 1. Shared Swift extraction | Cross-platform package, paths/process abstractions, shared tests | 3-5 engineer-weeks | +| 2. Windows daemon + CLI | Secure Named Pipe, multi-client events, local graph commands, startup | 3-5 weeks | +| 3. zmx Windows backend | CLI compatibility, ConPTY detach/reattach, resize, Unicode, crash tests | 6-10 weeks | +| 4. Full terminal-host gate | Extracted Winghostty runtime or production custom renderer; two surfaces with input/IME/clipboard/DPI | 8-16 weeks, very high uncertainty | +| 5. Minimal native shell | Project list, graph, node actions, one workspace, state updates | 8-12 weeks | +| 6. parity/hardening | Tabs/splits, attention UX, accessibility, packaging, agents | 8-14 weeks | + +Total: roughly **36-62 engineer-weeks** before remote parity and ARM64. One experienced +engineer should expect approximately 9-16 months; a small parallel team can reduce +calendar time, but the terminal-host gate is not parallelizable away. + +## Go/no-go checkpoints + +Proceed now with phases 1-2 and a source-integrated zmx prototype. Treat the complete zmx +backend as conditional on that prototype. + +Do not approve the full product port budget until: + +1. the full two-surface Ghostty host gate passes; +2. zmx runs at least one real coding agent through detach/send/reattach; +3. Named Pipe ACLs, connected read/write deadlines, cancellation, and frame bounds are proven; +4. request correlation, version negotiation, event-subscription semantics, and interleaved + multi-client tests are complete; +5. Swift runtime packaging size and installer behavior are measured. + +If the Ghostty host gate fails, reconsider the UI host/renderer choice without discarding +the successful shared Swift, daemon, CLI, and zmx work. diff --git a/investigation/windows-process-and-shell-semantics.md b/investigation/windows-process-and-shell-semantics.md new file mode 100644 index 00000000..ab64b999 --- /dev/null +++ b/investigation/windows-process-and-shell-semantics.md @@ -0,0 +1,67 @@ +# Windows process and shell semantics + +## Spike result + +`investigation/spikes/swift-process` uses Swift Foundation `Process` on Windows 11. + +Observed: + +```text +direct.arguments=["space value", "quote\"value", "雪"] +direct.environment=inherited +swift-process direct-exe-argv-cwd-environment: ok +swift-process direct-cmd-launches=true +swift-process direct-ps1-launches=false +swift-process cmd-hosted-shim: ok +swift-process powershell-hosted-shim: ok +``` + +Conclusions: + +- `Process` is sufficient for direct `.exe` launches with argv, Unicode, cwd, and environment. +- On this toolchain, `.cmd` launches directly and preserves a spaced argument, but GraphCode should still classify executable extensions explicitly rather than rely on undocumented dispatch behavior. +- `.ps1` does not launch directly. It needs `powershell.exe` or `pwsh.exe`. +- POSIX shell strings and quoting must not be translated mechanically to Windows. + +## Proposed launch policy + +| Input | Windows launch | +|---|---| +| `.exe`, extensionless native executable | Direct suspended `CreateProcessW` launch, assigned to a Job Object before resume; `STARTUPINFOEXW` restricts inheritance to the three stdio handles | +| `.cmd`, `.bat` | Noninteractive `cmd.exe /d /q /s /c` with a parser-escaped command argument; no stdin banner or prompt | +| `.ps1` | Prefer `pwsh.exe -NoLogo -NoProfile -File`; optionally fall back to Windows PowerShell | +| npm shim | Resolve the actual `.cmd`/`.ps1`/`.exe` and apply the matching rule | +| shell predicate | Explicit configured shell; PowerShell should be the native default | +| remote command | Local `ssh.exe` argv plus an explicitly POSIX-quoted remote command | +| WSL command | Explicit `wsl.exe -- ` mode, never implicit path conversion | + +Every Windows `CreateProcessW` command line begins with the quoted, backslash-normalized +executable path as `argv[0]`, including when `lpApplicationName` is supplied. This preserves +native executable argument conventions while keeping the noninteractive `cmd.exe` transport +compatible with paths containing spaces. + +## Required tests + +- executable and working-directory paths with spaces +- quotes, backslashes, empty arguments, Unicode, and trailing backslashes +- `.exe`, `.cmd`, `.bat`, `.ps1`, npm shims +- `pwsh.exe`, Windows PowerShell, `cmd.exe`, and optional WSL +- environment removal/addition without mutating the parent process +- cancellation and process-tree termination +- stdout/stderr draining without deadlock +- timeout and ambiguous completion behavior +- native process-group/job containment before the child can spawn descendants +- Windows successful-root cleanup terminates and closes the Job Object before pipe draining +- Darwin successful-root cleanup terminates background group members before releasing pipes +- Windows junction/symlink final targets are resolved before filesystem-root rejection; extended + UNC prefixes (including case variants of the `UNC` device component) and trailing + dot/separator variants are normalized even when resolution fails +- concurrent Windows launches do not cross-inherit pipe handles + +## Source consequences + +- Replace `ZmxSessionLauncher.loginShellInvocation` with platform command builders. +- Replace hard-coded `/bin/zsh` in `ShellPredicateEvaluator`. +- Split POSIX hook generation in `PresenceHooks` from lifecycle policy. +- Resolve `ssh` from PATH/System32 rather than `/usr/bin/ssh`. +- Preserve direct argv whenever possible; use shell text only when shell evaluation is the feature. diff --git a/investigation/winghostty-fork-plan.md b/investigation/winghostty-fork-plan.md new file mode 100644 index 00000000..ed1a9611 --- /dev/null +++ b/investigation/winghostty-fork-plan.md @@ -0,0 +1,537 @@ +# Winghostty fork, extraction, and GraphCode extension plan + +## Decision and assumptions + +This plan assumes GraphCode will: + +1. fork `amanthanvi/winghostty`; +2. maintain the fork against Winghostty and Ghostty upstream; +3. extract an embeddable Win32 terminal-host layer from Winghostty's internal runtime; +4. build the GraphCode Windows shell in Zig + Win32 around that layer; and +5. keep GraphCode orchestration in Swift `graphcoded`, connected through Named Pipes. + +The fork is not treated as the GraphCode application. It is the source and maintenance +home for a reusable Windows Ghostty host. + +## Target architecture + +```text +graphcode-windows.exe graphcoded.exe +Zig + Win32 Swift + | | + | Named Pipe protocol | + +--------------------------------------+ + | + +-- GraphCode-owned top-level HWND + | sidebar, graph canvas, dialogs, navigation + | + +-- winghostty-host package + | + +-- terminal child HWND A + | WGL/OpenGL Ghostty renderer + | child process: zmx attach + | + +-- terminal child HWND B + WGL/OpenGL Ghostty renderer + child process: zmx attach + +zmx session daemon + owns the coding agent's persistent ConPTY +``` + +GraphCode owns product UI and layout. The extracted Winghostty layer owns complete +terminal surfaces: rendering, font metrics, input encoding, IME, selection, clipboard, +DPI, accessibility, repaint scheduling, and the short-lived `zmx attach` client process. + +## Repository strategy + +Create or use: + +```text +coneilen/winghostty +``` + +Configure remotes: + +```text +origin coneilen/winghostty +winghostty amanthanvi/winghostty +ghostty ghostty-org/ghostty +``` + +The production baseline is an atomic compatibility tuple: + +```text +{ Winghostty SHA, Winghostty Ghostty dependency SHA, Zig, MSVC, Windows SDK } +``` + +Do not merge Ghostty upstream directly into the fork. Upgrade Ghostty only through a +reviewed Winghostty update or an isolated vendor-bump branch that reruns the original-app, +external one-surface, and two-surface gates. + +Long-lived branches: + +| Branch | Purpose | +|---|---| +| `upstream` | Unmodified Winghostty synchronization point | +| `graphcode-host` | Extraction and reusable host API | +| `graphcode-integration` | Temporary integration branch only when a change spans both repositories | + +Rules: + +- Never mix GraphCode canvas/product code into the Winghostty fork. +- Keep extraction commits separate from behavior changes. +- Rebase unpublished extraction work; merge/version branches already pinned by GraphCode. +- Regularly synchronize the atomic Winghostty baseline; do not independently advance its + Ghostty dependency. +- Pin GraphCode to an exact fork commit. +- Record Winghostty and Ghostty upstream bases in dependency metadata. +- Send generally useful extraction fixes upstream even if the complete host API is not accepted. + +## Extracted package + +Create a package/module such as: + +```text +src/win32_host/ + Host.zig + Surface.zig + SurfaceConfig.zig + Callbacks.zig + Command.zig + Clipboard.zig + Input.zig + Ime.zig + Accessibility.zig + Renderer.zig + Dpi.zig + Errors.zig +``` + +Initial consumption should be a pinned Zig package because both Winghostty and the +GraphCode Windows shell are Zig. Do not add a stable C ABI until another language actually +needs to embed the host. + +The extracted package must not depend on: + +- Winghostty tabs or split-tree product UI +- Winghostty settings window +- Winghostty update/recovery flows +- Winghostty top-level window chrome +- Winghostty application IPC +- Winghostty session persistence +- GraphCode types or daemon protocol + +## Minimum host API + +Illustrative Zig boundary: + +```zig +pub const Host = struct { + pub fn init(allocator: Allocator, options: HostOptions) !Host; + pub fn deinit(self: *Host) void; + pub fn createSurface(self: *Host, options: SurfaceOptions) !*Surface; + pub fn drainUiThreadWork(self: *Host) !void; +}; + +pub const SurfaceOptions = struct { + parent_hwnd: HWND, + bounds: Rect, + command: []const []const u8, + cwd: ?[]const u8, + environment: []const EnvironmentEntry, + callbacks: SurfaceCallbacks, +}; + +pub const Surface = struct { + pub fn setBounds(self: *Surface, bounds: Rect) !void; + pub fn setVisible(self: *Surface, visible: bool) void; + pub fn focus(self: *Surface) !void; + pub fn setTheme(self: *Surface, theme: Theme) !void; + pub fn setFontScale(self: *Surface, scale: f32) !void; + pub fn destroy(self: *Surface) !void; +}; +``` + +Embedding contract: + +- GraphCode owns the sole UI thread and `GetMessage`/`TranslateMessage`/`DispatchMessage` + loop. +- The host owns registered child-window procedures; it does not run a competing loop. +- `drainUiThreadWork` is optional non-blocking work called by GraphCode on the UI thread. +- Every host/surface call and callback documents UI-thread affinity. +- `createSurface` copies command, cwd, environment, and callback configuration. +- `destroy` is synchronous or explicitly awaitable and guarantees no callbacks, renderer + access, process access, or posted child-window work after completion. + +Callbacks should report: + +- process exit +- title and working-directory changes +- bell/notification +- redraw requested +- focus change +- fatal surface error + +The caller supplies `parent_hwnd`; the host must not create or own the GraphCode top-level +window. + +## Ownership boundary + +| Concern | Owner | +|---|---| +| Top-level HWND, app lifetime | GraphCode Windows | +| Sidebar, graph canvas, cards, dialogs | GraphCode Windows | +| Tabs/splits and terminal workspace model | GraphCode Windows | +| Terminal child HWND | Winghostty host | +| WGL/OpenGL renderer and glyph resources | Winghostty host | +| Keyboard, mouse, IME, selection | Winghostty host | +| Terminal clipboard and UIA text provider | Winghostty host | +| `zmx attach` child process/ConPTY | Winghostty host | +| Persistent agent process and scrollback state | zmx | +| Graph/session/backend orchestration | Swift GraphcodeKit/graphcoded | +| Project/graph persistence | Swift GraphcodeKit | + +GraphCode should implement its own tab/split layout using `TerminalLayout`; do not import +Winghostty's product-level tab/split UI. + +## Phased work + +### Phase 0: Fork governance and reproducible baseline + +Tasks: + +- fork Winghostty and configure remotes; +- document exact upstream revisions and license provenance; +- pin the supported Zig, MSVC, Windows SDK, and dependency versions; +- reproduce the full Winghostty build in clean Windows CI; +- fix the observed absolute-child-cwd build-runner failure; +- add a smoke workflow that launches Winghostty and opens one terminal. + +Exit criteria: + +- clean clone builds without local path assumptions; +- CI produces a runnable artifact; +- upstream synchronization procedure is documented. + +Estimate: 1-2 engineer-weeks. + +### Phase 1: Separate runtime from Winghostty application policy + +Move or refactor code in small commits: + +1. isolate Win32 types/errors/helpers; +2. isolate renderer/context creation; +3. isolate terminal child `Surface`; +4. isolate input/IME/clipboard/DPI/accessibility; +5. replace direct `App`/`Host` product calls with callbacks/interfaces; +6. make top-level ownership injectable. + +Keep Winghostty behavior unchanged after every commit. + +Exit criteria: + +- normal Winghostty still builds and behaves the same; +- terminal surface code no longer imports tabs, settings UI, updater, recovery, or app IPC; +- one internal Winghostty test host creates a surface under a supplied parent HWND. + +Estimate: 3-5 weeks. + +### Phase 2: Publish the embeddable `win32_host` package + +Tasks: + +- define `Host`, `Surface`, options, callbacks, and errors; +- accept a caller-owned parent HWND; +- accept direct command argv, cwd, and environment; +- make surface lifetime deterministic; +- support independent WGL contexts and renderer resources; +- provide a minimal external example with custom GraphCode-like chrome. + +Acceptance tests: + +- create/destroy one surface 100 times; +- resize continuously across per-monitor DPI changes; +- run `cmd.exe`, PowerShell, and an arbitrary executable; +- verify Unicode, dead keys, IME, selection, clipboard, mouse wheel, and links; +- close the top-level host without leaked threads/processes/HWNDs/HDCs/HGLRCs. + +Exit criteria: + +- a standalone executable outside Winghostty creates a complete rendered terminal surface; +- no Winghostty product window, tabs, or settings code is linked. + +Estimate: 3-5 weeks. + +### Phase 3: Two-surface architecture gate + +Build the exact GraphCode-shaped proof: + +```text +GraphCode-owned top-level HWND + custom header/panel + terminal child A + terminal child B +``` + +Test: + +- independent commands and terminal state; +- independent focus and keyboard input; +- repeated focus switching; +- simultaneous output; +- resize and DPI; +- IME in each surface; +- copy in one and paste in the other; +- accessibility tree exposes both; +- a UI Automation client verifies focus, role/name, terminal text exposure and updates, + and selection/copy behavior for each surface; +- destroy/recreate one while the other remains active. + +Exit criteria: + +- all tests pass without using Winghostty's top-level application UI; +- renderer/context/thread ownership is documented; +- this becomes the full Windows UI go/no-go checkpoint. + +Estimate: 2-4 weeks. + +### Phase 4: zmx attach integration + +Dependency: the source-integrated Windows zmx protocol prototype must pass first. + +Tasks: + +- launch `zmx.exe attach ` as the surface command; +- close a surface without killing the zmx session; +- recreate and reattach with VT state restored by zmx; +- test input injection while attached and detached; +- define exit/reconnect UI when zmx or the attach client fails. + +Acceptance: + +- session survives terminal surface destruction; +- session survives GraphCode Windows process exit; +- two GraphCode surfaces can attach to two sessions concurrently; +- one policy is chosen for two surfaces attaching to one session: reject, shared attach, + or explicit leadership transfer; +- that policy is tested concurrently for input routing, resize ownership, transfer or + rejection, detach/reconnect, and continued session health. + +Estimate: 2-4 weeks. + +### Parallel prerequisite: Windows daemon, CLI, and protocol + +This can run alongside Winghostty extraction but must complete before Phase 5. + +Implement and prove: + +- cross-platform Swift package boundaries; +- `PlatformPaths`, `ProcessRunner`, and `ShellStrategy`; +- Windows `graphcoded.exe` and `graphcode.exe`; +- current-user Named Pipe ACL and collision-resistant endpoint naming; +- bounded framing, partial-frame handling, backpressure, and non-reading peers; +- connected read/write deadlines and cancellation; +- request correlation and protocol version negotiation; +- event subscription, ordering, reconnect, and replay semantics; +- interleaved multi-client CLI/UI tests. + +Exit criteria: + +- Windows CLI creates, reads, and updates a local graph through `graphcoded`; +- two clients issue interleaved commands without misattributing responses; +- reconnect and daemon restart behavior are deterministic; +- security tests show another user cannot open the pipe. + +Estimate: 6-10 engineer-weeks, including shared Swift extraction. + +### Phase 5: GraphCode Windows shell scaffold + +Dependencies: + +- Phase 3 two-surface host gate; +- Phase 4 zmx attach integration; +- Windows daemon/CLI/protocol prerequisite. + +Create `graphcode-windows/`: + +```text +build.zig +src/ + main.zig + App.zig + MainWindow.zig + DaemonClient.zig + GraphCanvas.zig + Sidebar.zig + TerminalWorkspace.zig + TerminalSurface.zig + InputRouter.zig + Accessibility.zig +``` + +Implement: + +- top-level window and message loop; +- Named Pipe daemon client; +- project/sidebar list; +- basic graph cards and edges; +- node selection/open; +- one terminal workspace; +- two-surface support; +- create/stop/send actions; +- live graph/presence events. + +Exit criteria: + +- local project can be opened through Windows `graphcoded`; +- a node can be created and opened; +- its persistent zmx terminal can be closed and reopened; +- two nodes can be viewed simultaneously. + +Estimate: 6-10 weeks. + +### Phase 6: Workspace and graph parity + +Implement in GraphCode, not the Winghostty fork: + +- tabs/splits and `TerminalLayout` persistence; +- keyboard focus/navigation; +- jump palette and needs-you navigation; +- full node/edge forms; +- graph pan/zoom/drag/connect; +- context menus and native dialogs; +- theme and font settings. + +Estimate: 6-10 weeks. + +### Phase 7: Production hardening + +- UIA/accessibility audit; +- IME and international keyboard matrix; +- GPU/device/context failure recovery; +- renderer crash and teardown tests; +- code signing and installer; +- Swift and Zig runtime packaging; +- telemetry/logging consistent with project policy; +- updater; +- performance and memory tests with 10-20 live surfaces; +- upstream/fork sync rehearsal. + +Estimate: 6-10 weeks. + +## Proposed PR sequence in the Winghostty fork + +Keep changes reviewable: + +1. CI/toolchain baseline and cwd fix. +2. Win32 error/type helpers extraction. +3. Renderer context lifecycle extraction. +4. Surface options and caller-owned parent HWND. +5. Callback interface replacing direct app policy calls. +6. Input/IME extraction. +7. Clipboard/selection extraction. +8. DPI/focus/accessibility extraction. +9. External one-surface example. +10. External two-surface example. +11. Zig package export and documentation. + +Do not submit one large “make Winghostty embeddable” patch. + +## Testing matrix + +### Automated + +- build with pinned Zig/MSVC/SDK; +- one and two surface creation; +- repeated create/destroy; +- bounds/DPI calculations; +- input encoders; +- clipboard round-trip; +- process exit callbacks; +- leaked HANDLE/HWND/HDC/HGLRC checks; +- upstream Winghostty regression smoke. + +### Manual hardware/UX + +- Intel/AMD/NVIDIA GPUs; +- 100%, 125%, 150%, 200% DPI; +- multi-monitor mixed DPI; +- US/international/dead-key layouts; +- Chinese/Japanese/Korean IMEs; +- screen reader/UIA; +- remote desktop; +- sleep/wake and GPU driver reset; +- long-running high-output agent TUIs. + +## Fork maintenance budget + +Reserve ongoing capacity: + +- weekly/biweekly upstream review; +- monthly synchronization at minimum; +- immediate security/dependency updates; +- one maintained patch stack with documented upstream bases; +- CI against the pinned compatibility tuple and explicit vendor-bump branches. + +Expected steady-state cost: approximately 0.1-0.25 engineer after extraction, with spikes +around major Ghostty/Winghostty renderer or build changes. + +## Risks and controls + +| Risk | Control | +|---|---| +| Fork diverges | Small layered patch series, atomic baseline sync, upstream generic fixes | +| Host API becomes GraphCode-specific | No GraphCode types/protocol in the fork | +| Renderer/context leaks | Repeated lifecycle tests and explicit ownership | +| Winghostty product regressions | Build/run original app after every extraction PR | +| Tabs/splits duplicated | Winghostty owns surfaces; GraphCode owns workspace layout | +| zmx and terminal host both claim process ownership | Host owns only `zmx attach`; zmx owns agent ConPTY | +| Build toolchain instability | Pin Zig/MSVC/SDK and maintain clean CI images | +| Accessibility postponed | Include UIA in the extracted surface acceptance gate | + +## Staffing and schedule + +Recommended parallel streams: + +| Stream | Work | +|---|---| +| Terminal platform engineer | Winghostty fork, extraction, renderer/input/IME | +| Systems engineer | zmx Windows protocol backend | +| Swift engineer | shared core, daemon, CLI, paths/process/IPC | +| Windows product engineer | GraphCode shell/canvas/workspace | + +Critical path: + +```text +Winghostty baseline -> embeddable host -> two-surface gate ---+ + | +source-integrated zmx backend -> zmx attach integration ------+-> minimal shell + | +Windows daemon + CLI + hardened protocol ---------------------+ +``` + +The work listed here plus shared Swift, daemon/CLI, and zmx is approximately **41-70 +engineer-weeks**, excluding remote parity and ARM64. A single experienced engineer should +expect roughly 10-18 months. Parallel staffing shortens calendar time but does not remove +the 8-16 week high-uncertainty terminal-host extraction segment. + +## First 30 days + +1. Create the fork and CI baseline. +2. Fix and document the Winghostty build/toolchain issue. +3. Produce an import/dependency graph rooted at `Surface`. +4. Identify every direct `App`/`Host` policy dependency. +5. Extract caller-owned parent HWND creation. +6. Publish a one-surface external example. +7. Start the source-integrated zmx Windows protocol prototype in parallel. +8. Start the Windows daemon/CLI/path/process/protocol prerequisite in parallel. + +At day 30, review: + +- whether the surface import graph is shrinking as expected; +- whether original Winghostty behavior remains intact; +- whether upstream is receptive to generic extraction patches; +- whether the expected two-surface gate still fits the estimate. + +If not, stop before GraphCode product UI work and reassess the fork cost. diff --git a/investigation/zmx-windows-feasibility.md b/investigation/zmx-windows-feasibility.md new file mode 100644 index 00000000..65a02a19 --- /dev/null +++ b/investigation/zmx-windows-feasibility.md @@ -0,0 +1,83 @@ +# zmx Windows feasibility + +## Current architecture + +zmx runs one daemon per session. The daemon owns the PTY, terminal/scrollback state, +labels, cwd, and child process. Clients attach through local IPC. State is memory-resident. + +Its protocol currently includes tags 0-18, an asserted 8-byte packed header, and an +asserted 552-byte info structure. GraphCode uses at least: + +```text +run -d ... +attach +send +get [label] +set +kill +``` + +## Unix implementation dependencies + +- `forkpty`, termios, ioctl +- double fork, `setsid`, `/dev/null`, `dup2` +- `fork`, `execve/execvpe`, `waitpid`, `kill`, process groups +- AF_UNIX filesystem sockets and stale socket cleanup +- `poll`, self-pipe signal handling, SIGWINCH/SIGHUP/SIGTERM +- UID, XDG/HOME/TMP paths and POSIX modes +- `/bin/sh`, bash task mode, Unix quoting and utilities + +## Windows mapping + +| Unix | Windows | +|---|---| +| PTY/forkpty | ConPTY / `HPCON` | +| AF_UNIX | Named Pipes | +| process group | Job Object | +| fork/exec | `CreateProcessW` + `STARTUPINFOEX` | +| ioctl resize | `ResizePseudoConsole` | +| poll/signals | overlapped I/O plus event/IOCP waits | +| UID/XDG runtime path | per-user LocalAppData state | +| POSIX task shell | explicit PowerShell/cmd policy or deferred compatibility mode | + +## Spike result + +`investigation/spikes/zmx-conpty` combines ConPTY, Named Pipes, and a Job Object. + +Validated in a custom line-protocol primitive spike: + +1. Start an interactive child. +2. Send `echo BEFORE`. +3. Detach the client without killing the child. +4. Send `echo DETACHED` while detached. +5. Reattach and receive a snapshot containing both outputs. +6. Stop and remove the process tree; subsequent pipe connection fails. + +This proves ConPTY child survival across short Named Pipe connections and Job Object +lifetime control. It does not prove zmx attach/detach parity: + +- no real zmx header/tags or CLI implementation +- no long-lived bidirectional attach stream +- `DETACH` stores no attachment state +- `SNAPSHOT` returns a capped raw byte buffer, not libghostty-vt screen reconstruction +- no resize or concurrent attach leadership policy +- no real coding-agent TUI + +## GraphCode fork risk + +GraphCode pins `scgopi/zmx` commit `a8739f4f64f7b716f24cc51c4883938f4daaf284`. +Its mouse-input patch is still relevant but is 26 commits behind current upstream and +conflicts when cherry-picked. Rebase/upstream it before beginning Windows work. + +## Decision + +Run a source-integrated prototype against rebased zmx before approving the full port. +If it succeeds, port zmx as one cross-platform codebase with platform modules for +process/PTY, IPC, daemon lifecycle, event waits, resize/control, paths/security, and +shell tasks. Create a separate `zmx-win` only if upstream rejects the abstractions or +required CLI semantics. + +Confidence: + +- High: ConPTY, Named Pipe, and Job Object primitives. +- Medium-low: real zmx attach/VT/CLI, task mode, signal/control, agent TUI, and crash parity. diff --git a/windows-tests/GraphCommandInteropTests.swift b/windows-tests/GraphCommandInteropTests.swift new file mode 100644 index 00000000..a509db5f --- /dev/null +++ b/windows-tests/GraphCommandInteropTests.swift @@ -0,0 +1,168 @@ +import Foundation +import XCTest +import IdentifiedCollections + +@testable import GraphcodeKit + +final class GraphCommandInteropTests: XCTestCase { + func testCreateEdgeWirePayloadDecodesToSwiftGraphCommand() throws { + let data = try fixture("daemon-v2-create-edge.json") + let envelope = try JSONDecoder().decode(RequestEnvelope.self, from: data) + XCTAssertEqual( + envelope.command, + .graphCommand( + projectPath: "C:\\work\\graph", + command: .createEdge( + from: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!, + to: UUID(uuidString: "22222222-2222-4222-8222-222222222222")!, + spec: EdgeSpec()))) + } + + func testDeleteEdgeWirePayloadDecodesToSwiftGraphCommand() throws { + let data = try fixture("daemon-v2-delete-edge.json") + let envelope = try JSONDecoder().decode(RequestEnvelope.self, from: data) + XCTAssertEqual( + envelope.command, + .graphCommand( + projectPath: "C:\\work\\graph", + command: .deleteEdge(UUID(uuidString: "33333333-3333-4333-8333-333333333333")!))) + } + + func testLifecycleAndExtendedGraphFixturesDecodeToSwiftCommands() throws { + let node = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + let child = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! + let cases: [(String, DaemonCommand)] = [ + ("daemon-v2-close-project.json", .closeProject(path: "C:\\work\\graph")), + ("daemon-v2-forget-project.json", .forgetProject(path: "C:\\work\\graph")), + ("daemon-v2-delete-project-graph.json", .deleteProjectGraph(path: "C:\\work\\graph")), + ("daemon-v2-delete-node.json", .graphCommand(projectPath: "C:\\work\\graph", command: .deleteNode(node))), + ("daemon-v2-update-node.json", .graphCommand( + projectPath: "C:\\work\\graph", + command: .updateNode(node, update: NodeUpdate( + goalSummary: "Done", goalPredicate: "test -f done", pollIntervalSeconds: 30, + modelTier: .fast)))), + ("daemon-v2-memo-node.json", .graphCommand( + projectPath: "C:\\work\\graph", + command: .memoNode(node, text: "learned", from: nil))), + ("daemon-v2-refresh-usage.json", .graphCommand( + projectPath: "C:\\work\\graph", command: .refreshUsage)), + ("daemon-v2-arm-composite.json", .graphCommand( + projectPath: "C:\\work\\graph", command: .armComposite(node))), + ("daemon-v2-pilot-arm-refresh.json", .graphCommand( + projectPath: "C:\\work\\graph", command: .pilotComposite(node))), + ("daemon-v2-subgraph-command.json", .graphCommand( + projectPath: "C:\\work\\graph", + command: .subGraphCommand(nodeID: node, command: .deleteNode(child)))), + ] + for (name, expected) in cases { + let envelope = try JSONDecoder().decode(RequestEnvelope.self, from: fixture(name)) + XCTAssertEqual(envelope.command, expected, name) + } + } + + func testNodeDraftAndLoopGraphFixturesDecodeWithCompleteSwiftSchema() throws { + let draft = try JSONDecoder().decode(NodeDraft.self, from: fixture("swift-node-draft-valid.json")) + XCTAssertEqual(draft.id, UUID(uuidString: "22222222-2222-4222-8222-222222222222")) + XCTAssertNil(draft.backend) + XCTAssertEqual(draft.firstInstruction, "work") + + let graph = try JSONDecoder().decode(LoopGraph.self, from: fixture("swift-loopgraph-valid.json")) + XCTAssertEqual(graph.id, UUID(uuidString: "11111111-1111-4111-8111-111111111111")) + XCTAssertEqual(graph.project.path, "C:\\work\\graph") + XCTAssertTrue(graph.nodes.isEmpty) + XCTAssertTrue(graph.edges.isEmpty) + + XCTAssertThrowsError( + try JSONDecoder().decode( + LoopGraph.self, + from: Data(#"{"nodes":[],"edges":[]}"#.utf8))) + } + + func testPopulatedLoopGraphFixturesDecodeOrRejectInSwift() throws { + let graph = try JSONDecoder().decode( + LoopGraph.self, from: fixture("swift-loopgraph-populated-valid.json")) + XCTAssertEqual(graph.nodes.count, 1) + XCTAssertEqual(graph.edges.count, 1) + XCTAssertEqual(graph.nodes.first?.subGraph?.nodes.count, 1) + XCTAssertEqual(graph.nodes.first?.goal?.metricDirection, .minimize) + XCTAssertEqual(graph.edges.first?.payloadTransform, .template("payload {{output}}")) + XCTAssertThrowsError( + try JSONDecoder().decode( + LoopGraph.self, from: fixture("swift-loopgraph-populated-invalid.json"))) + } + + func testGeneratePopulatedSwiftAcceptanceFixture() throws { + let nested = LoopGraph( + id: UUID(uuidString: "33333333-3333-4333-8333-333333333333")!, + project: ProjectRef(path: "C:\\work\\nested", name: "Nested", lastOpenedAt: Date(timeIntervalSince1970: 1767225600)), + nodes: IdentifiedArrayOf(uniqueElements: [ + LoopNode( + id: UUID(uuidString: "44444444-4444-4444-8444-444444444444")!, + title: "Nested loop", + loopType: .composite, + firstInstruction: "nested work", + backend: .copilotCLI, + createdAt: Date(timeIntervalSince1970: 1767225600)) + ])) + let node = LoopNode( + id: UUID(uuidString: "22222222-2222-4222-8222-222222222222")!, + title: "Goal", + loopType: .goalBased, + checkDescription: "check", + triggerPrompt: "trigger", + firstInstruction: "work", + pausesBeforeWritesOnly: true, + goal: GoalSpec( + summary: "finish", predicate: "test -f done", pollIntervalSeconds: 30, + stallAfterSeconds: 600, metricCommand: "measure", metricDirection: .minimize), + backend: .claudeCode, + modelTier: .capable, + worktreeBinding: WorktreeRef( + id: "wt-1", repositoryPath: "C:\\repo", worktreePath: "C:\\repo-wt", branch: "feature"), + subGraph: nested, + pilotState: .armed, + usage: UsageSample(inputTokens: 12, outputTokens: 34, costUSD: 0.12, reportedAt: Date(timeIntervalSince1970: 1767225600)), + activity: "editing", + presence: PresenceReading(presence: .busy, confidence: .reported), + metricHistory: [MetricSample(value: 1.5, recordedAt: Date(timeIntervalSince1970: 1767225600))], + createdBy: UUID(uuidString: "55555555-5555-4555-8555-555555555555")!, + state: .running, + createdAt: Date(timeIntervalSince1970: 1767225600)) + let edge = LoopEdge( + id: UUID(uuidString: "66666666-6666-4666-8666-666666666666")!, + from: node.id, + to: node.id, + spec: EdgeSpec( + kind: .handoff, condition: .onSuccess, payloadTransform: .template("payload {{output}}"), + cycleGuard: CycleGuard( + maxIterations: 3, until: "test -f done", stopAfterPassesWithoutImprovement: 2), + spawnTargetProjectPath: "C:\\other"), + fireCount: 1) + let graph = LoopGraph( + id: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!, + project: ProjectRef(path: "C:\\work\\graph", name: "Graph", lastOpenedAt: Date(timeIntervalSince1970: 1767225600)), + nodes: IdentifiedArrayOf(uniqueElements: [node]), + edges: IdentifiedArrayOf(uniqueElements: [edge])) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = try encoder.encode(graph) + let roundTrip = try JSONDecoder().decode(LoopGraph.self, from: encoded) + XCTAssertEqual(roundTrip.nodes.count, 1) + XCTAssertEqual(roundTrip.edges.count, 1) + } + + private func fixture(_ name: String) throws -> Data { + try Data(contentsOf: fixtureURL(name)) + } + + private func fixtureURL(_ name: String) -> URL { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + return root.appendingPathComponent("graphcode-windows/fixtures/\(name)") + } +} + +private struct RequestEnvelope: Decodable { + let command: DaemonCommand +} diff --git a/windows-tests/QuickChatProtocolTests.swift b/windows-tests/QuickChatProtocolTests.swift new file mode 100644 index 00000000..d8030bea --- /dev/null +++ b/windows-tests/QuickChatProtocolTests.swift @@ -0,0 +1,149 @@ +import Foundation +import GraphcodeKit +import Testing + +@Suite +struct QuickChatProtocolTests { + @Test + func commandAndEventCodableRoundTrip() throws { + let commands: [DaemonCommand] = [ + .listQuickChats, + .createQuickChat(title: "Scratch", backend: .claudeCode), + .openQuickChat(id: UUID()), + .renameQuickChat(id: UUID(), title: "Renamed"), + .deleteQuickChat(id: UUID()), + ] + let encoder = JSONEncoder() + let decoder = JSONDecoder() + for command in commands { + #expect(try decoder.decode(DaemonCommand.self, from: encoder.encode(command)) == command) + } + + let chat = QuickChat(title: "Scratch") + let events: [DaemonEvent] = [ + .quickChatsListed([chat]), + .quickChatChanged(chat), + .quickChatDeleted(chat.id), + .quickChatActivity( + id: chat.id, + activity: QuickChatActivity(sequence: 2, text: "editing", presence: .unknown)), + ] + for event in events { + #expect(try decoder.decode(DaemonEvent.self, from: encoder.encode(event)) == event) + } + } + + @Test + func storeKeepsActivitySequenceOnStableChatIdentity() throws { + let directory = URL(fileURLWithPath: "quick-chat-protocol-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let store = QuickChatStore(baseDirectory: directory) + let chat = QuickChat(title: "Scratch") + try store.create(chat) + _ = try store.updateActivity(id: chat.id, activity: QuickChatActivity(sequence: 1, text: "first")) + _ = try store.updateActivity(id: chat.id, activity: QuickChatActivity(sequence: 2, text: "second")) + #expect(store.chat(id: chat.id)?.activity?.sequence == 2) + #expect(store.chat(id: chat.id)?.activity?.text == "second") + } + + @Test + func zmxFixtureUsesChatIdentityForAttachAndKill() throws { + let chat = QuickChat( + id: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!, + title: "Scratch", + backend: .claudeCode) + let node = LoopNode( + id: chat.id, + title: chat.title, + backend: chat.backend, + state: .idle, + createdAt: chat.createdAt) + let sessionName = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + #expect(sessionName == "graphcode-\(chat.id.uuidString)") + #expect(SurfaceRef.nodeID(fromZmxSessionName: sessionName) == chat.id) + } + + @Test + func launcherFailureIsExplicit() async { + let backend = CLISessionBackend( + kind: .claudeCode, + launch: { _, _ in }, + terminate: { _, _ in }, + sendInput: { _, _, _ in false }, + presence: { _, _ in .unknown }, + usage: { _, _ in nil }, + startResult: { _, _ in .failure(.failed("fixture start failure")) }, + terminateResult: { _, _ in .failure(.failed("fixture terminate failure")) }, + exists: { _, _ in false }, + enumerate: { [] }) + let node = LoopNode(title: "fixture") + #expect(await backend.startResult(node, nil) == .failure(.failed("fixture start failure"))) + let termination = await backend.terminateResult(node, nil) + if case .failure(.failed(let message)) = termination { + #expect(message == "fixture terminate failure") + } else { + Issue.record("termination failure was not surfaced") + } + } + + @Test + func corruptStoreIsNotTreatedAsEmptyForMutation() throws { + let directory = URL(fileURLWithPath: "quick-chat-corrupt-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("{not-json".utf8).write(to: directory.appendingPathComponent("quick-chats.json")) + let store = QuickChatStore(baseDirectory: directory) + #expect(throws: QuickChatStoreError.corruptOrUnreadable) { + try store.create(QuickChat(title: "must not overwrite")) + } + #expect(throws: QuickChatStoreError.corruptOrUnreadable) { + try store.loadResult() + } + } + + @Test + func v1ListQuickChatsReportsCorruptStoreInsteadOfHanging() async throws { + let directory = URL(fileURLWithPath: "quick-chat-v1-corrupt-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("{not-json".utf8).write(to: directory.appendingPathComponent("quick-chats.json")) + + let connection = QuickChatRecordingConnection() + let registry = ProjectRegistry( + persistenceDirectory: directory, + readPresence: nil, + enumerateQuickChatSessions: { [] }) + await registry.addConnection(id: connection.id, connection: connection) + await registry.handle(.listQuickChats, connectionID: connection.id) + + let frames = await connection.recordedFrames() + #expect(frames.count == 1) + #expect( + try JSONDecoder().decode(DaemonEvent.self, from: frames[0]) + == .errorOccurred("quick chat store is corrupt or unreadable")) + } +} + +private actor QuickChatRecordingConnection: DaemonConnection { + nonisolated let id = UUID() + nonisolated let endpoint: DaemonEndpoint = .namedPipe("\\\\.\\pipe\\graphcode-quick-chat-test") + private var frames: [Data] = [] + + func receiveFrame() async throws -> Data { + throw RecordingError.closed + } + + func sendFrame(_ data: Data) async throws { + frames.append(data) + } + + func close() async throws {} + + func recordedFrames() -> [Data] { + frames + } + + private enum RecordingError: Error { + case closed + } +} diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift new file mode 100644 index 00000000..11d7df34 --- /dev/null +++ b/windows-tests/WindowsDaemonTests.swift @@ -0,0 +1,1521 @@ +import Foundation +import XCTest + +@testable import GraphcodeKit + +#if os(Windows) + import WinSDK +#endif + +final class WindowsDaemonTests: XCTestCase { + func testGraphcodeSettingsPersistAllProductChoices() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-settings-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("settings.json") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let settings = GraphcodeSettings( + defaultBackend: .copilotCLI, + defaultModelTier: .capable, + codexApprovals: .unsandboxed, + claudePermissionMode: .bypassPermissions, + copilotPermissions: .ask, + briefsSessionsAboutTheGraph: false, + autoSelectsModel: true, + showsActivityStrip: true, + betaUpdates: true) + XCTAssertTrue(GraphcodeSettingsStore.save(settings, to: url)) + XCTAssertEqual(GraphcodeSettingsStore.load(from: url), settings) + } + + #if os(Windows) + func testEndpointIsPerUserAndSupportDirectory() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-identity-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let first = try WindowsNamedPipeEndpoint.name( + environment: [ + SupportDirectory.environmentKey: root.appendingPathComponent("graphcode-a").path + ]) + let second = try WindowsNamedPipeEndpoint.name( + environment: [ + SupportDirectory.environmentKey: root.appendingPathComponent("graphcode-b").path + ]) + + XCTAssertTrue(first.hasPrefix("\\\\.\\pipe\\graphcode-")) + XCTAssertNotEqual(first, second) + XCTAssertLessThan(first.utf8.count, 240) + } + + func testDaemonInstanceLockRejectsSecondOwner() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-lock-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ + SupportDirectory.environmentKey: root.path + ] + let first = try WindowsDaemonInstanceLock(environment: environment) + XCTAssertThrowsError(try WindowsDaemonInstanceLock(environment: environment)) { error in + XCTAssertEqual(error as? WindowsPipeError, .instanceAlreadyRunning) + } + withExtendedLifetime(first) {} + } + + func testStartupReservationSerializesCompetingLaunches() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-startup-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [SupportDirectory.environmentKey: root.path] + var first: WindowsDaemonStartupReservation? = + try WindowsDaemonStartupReservation(environment: environment) + withExtendedLifetime(first) {} + let started = DispatchSemaphore(value: 0) + let completed = DispatchSemaphore(value: 0) + final class Result: @unchecked Sendable { + let lock = NSLock() + var succeeded = false + } + let result = Result() + DispatchQueue.global(qos: .utility).async { + started.signal() + if let second = try? WindowsDaemonStartupReservation(environment: environment) { + result.lock.lock() + result.succeeded = true + result.lock.unlock() + withExtendedLifetime(second) {} + } + completed.signal() + } + XCTAssertEqual(started.wait(timeout: .now() + 1), .success) + XCTAssertEqual(completed.wait(timeout: .now() + 0.2), .timedOut) + first = nil + XCTAssertEqual(completed.wait(timeout: .now() + 2), .success) + result.lock.lock() + defer { result.lock.unlock() } + XCTAssertTrue(result.succeeded) + } + + func testRendezvousSecretPersistsRotatesAndScopesTaskIdentity() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-rendezvous-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let firstDirectory = root.appendingPathComponent("first", isDirectory: true) + let secondDirectory = root.appendingPathComponent("second", isDirectory: true) + let firstEnvironment = [SupportDirectory.environmentKey: firstDirectory.path] + let secondEnvironment = [SupportDirectory.environmentKey: secondDirectory.path] + + let first = try WindowsNamedPipeEndpoint.name(environment: firstEnvironment) + XCTAssertEqual(first, try WindowsNamedPipeEndpoint.name(environment: firstEnvironment)) + let secretFile = firstDirectory.appendingPathComponent(".graphcode-rendezvous.secret") + XCTAssertTrue(FileManager.default.fileExists(atPath: secretFile.path)) + try FileManager.default.removeItem(at: secretFile) + try Data("corrupt".utf8).write(to: secretFile) + let rotated = try WindowsNamedPipeEndpoint.name(environment: firstEnvironment) + XCTAssertNotEqual(first, rotated) + XCTAssertNotEqual( + try WindowsNamedPipeEndpoint.taskName(environment: firstEnvironment), + try WindowsNamedPipeEndpoint.taskName(environment: secondEnvironment)) + XCTAssertNotEqual( + rotated, + try WindowsNamedPipeEndpoint.name(environment: secondEnvironment)) + } + + func testRendezvousSecretPublicationRetriesPartialRead() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-rendezvous-partial-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [SupportDirectory.environmentKey: root.path] + let first = try WindowsNamedPipeEndpoint.name(environment: environment) + let secretFile = root.appendingPathComponent(".graphcode-rendezvous.secret") + let complete = try Data(contentsOf: secretFile) + let partialHandle = try FileHandle(forWritingTo: secretFile) + try partialHandle.truncate(atOffset: 0) + try partialHandle.write(contentsOf: Data([0x01])) + try partialHandle.close() + + let writer = Task.detached { + try await Task.sleep(for: .milliseconds(25)) + let handle = try FileHandle(forWritingTo: secretFile) + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: complete) + try handle.close() + } + let recovered = try WindowsNamedPipeEndpoint.name(environment: environment) + _ = try await writer.value + XCTAssertEqual(recovered, first) + XCTAssertFalse( + (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) + .contains { $0.pathExtension == "tmp" }) ?? false) + } + + func testSupportAliasesShareResolvedIdentity() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-junction-\(UUID().uuidString)", isDirectory: true) + let target = root.appendingPathComponent("target", isDirectory: true) + let alias = root.appendingPathComponent("alias", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + + let systemRoot = + ProcessInfo.processInfo.environment["SystemRoot"] + ?? ProcessInfo.processInfo.environment["WINDIR"] + ?? "C:\\Windows" + let commandPrompt = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["ComSpec"] + ?? ProcessInfo.processInfo.environment["COMSPEC"] + ?? URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("cmd.exe").path) + guard FileManager.default.fileExists(atPath: commandPrompt.path) else { + throw XCTSkip("cmd.exe is unavailable in this Windows environment") + } + let result = try await FoundationProcessRunner().run( + ProcessRequest( + executable: commandPrompt, + arguments: [ + "/D", "/S", "/C", + "mklink /J \"\(alias.path)\" \"\(target.path)\"", + ])) + guard result.exitCode == 0 else { + throw XCTSkip("junction creation is unavailable in this Windows environment") + } + + let directEnvironment = [SupportDirectory.environmentKey: target.path] + let aliasEnvironment = [SupportDirectory.environmentKey: alias.path] + XCTAssertEqual( + SupportDirectory.url(environment: directEnvironment, homeDirectory: root), + SupportDirectory.url(environment: aliasEnvironment, homeDirectory: root)) + XCTAssertEqual( + try WindowsNamedPipeEndpoint.name(environment: directEnvironment), + try WindowsNamedPipeEndpoint.name(environment: aliasEnvironment)) + XCTAssertEqual( + try WindowsNamedPipeEndpoint.taskName(environment: directEnvironment), + try WindowsNamedPipeEndpoint.taskName(environment: aliasEnvironment)) + let lock = try WindowsDaemonInstanceLock(environment: directEnvironment) + XCTAssertThrowsError(try WindowsDaemonInstanceLock(environment: aliasEnvironment)) { error in + XCTAssertEqual(error as? WindowsPipeError, .instanceAlreadyRunning) + } + withExtendedLifetime(lock) {} + } + + func testRendezvousRotationCannotAllowSecondDaemon() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-running-rotation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [SupportDirectory.environmentKey: root.path] + let secret = root.appendingPathComponent(".graphcode-rendezvous.secret") + let taskName = try WindowsNamedPipeEndpoint.taskName(environment: environment) + let firstPipe = try WindowsNamedPipeEndpoint.name(environment: environment) + let lock = try WindowsDaemonInstanceLock(environment: environment) + XCTAssertTrue(firstPipe.hasPrefix("\\\\.\\pipe\\graphcode-")) + XCTAssertTrue(FileManager.default.fileExists(atPath: secret.path)) + try FileManager.default.removeItem(at: secret) + try Data("invalid-secret".utf8).write(to: secret) + + do { + _ = try WindowsNamedPipeEndpoint.name(environment: environment) + XCTFail("active daemon secret was rotated") + } catch { + XCTAssertEqual(error as? WindowsPipeError, .rendezvousSecretInUse) + } + XCTAssertEqual(taskName, try WindowsNamedPipeEndpoint.taskName(environment: environment)) + XCTAssertThrowsError(try WindowsDaemonInstanceLock(environment: environment)) { error in + XCTAssertEqual(error as? WindowsPipeError, .instanceAlreadyRunning) + } + withExtendedLifetime(lock) {} + } + + func testRendezvousRotationWhileDaemonRunningKeepsExistingClientConnected() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-running-client-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let environment = [SupportDirectory.environmentKey: root.path] + let pipeName = try WindowsNamedPipeEndpoint.name(environment: environment) + let lock = try WindowsDaemonInstanceLock(environment: environment) + let listener = try WindowsNamedPipeListener(pipeName: pipeName) + defer { Task { try? await listener.close() } } + let server = Task { + let connection = try await listener.accept() + let frame = try await connection.receiveFrame() + try await connection.sendFrame(frame) + try await connection.close() + } + let client = try WindowsNamedPipeClient.connect(to: pipeName) + let secret = root.appendingPathComponent(".graphcode-rendezvous.secret") + try FileManager.default.removeItem(at: secret) + try Data("invalid-secret".utf8).write(to: secret) + + do { + _ = try WindowsNamedPipeEndpoint.name(environment: environment) + XCTFail("active daemon secret was rotated") + } catch { + XCTAssertEqual(error as? WindowsPipeError, .rendezvousSecretInUse) + } + let payload = Data("still-connected".utf8) + try await client.sendFrame(payload) + let received = try await client.receiveFrame() + XCTAssertEqual(received, payload) + try await client.close() + try await server.value + withExtendedLifetime(lock) {} + } + + func testDaemonInstanceLockUsesGlobalCurrentUserScopedNameAndDescriptor() { + let sid = "S-1-5-21-100-200-300-400" + let name = WindowsDaemonInstanceLock.name( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "fedcba9876543210fedcba9876543210") + XCTAssertTrue(name.hasPrefix("Global\\")) + XCTAssertFalse(name.hasPrefix("Local\\")) + XCTAssertEqual( + WindowsDaemonInstanceLock.securityDescriptor(for: sid), + "D:P(A;;GA;;;\(sid))") + XCTAssertFalse( + WindowsDaemonInstanceLock.securityDescriptor(for: sid).contains("WD")) + XCTAssertNotEqual( + WindowsNamedPipeEndpoint.taskName( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "fedcba9876543210fedcba9876543210"), + WindowsNamedPipeEndpoint.taskName( + sid: "S-1-5-21-900-800-700-600", + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "fedcba9876543210fedcba9876543210")) + XCTAssertEqual( + WindowsNamedPipeEndpoint.taskName( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "fedcba9876543210fedcba9876543210"), + WindowsNamedPipeEndpoint.taskName( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "0123456789abcdef0123456789abcdef")) + XCTAssertEqual( + WindowsDaemonInstanceLock.name( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "fedcba9876543210fedcba9876543210"), + WindowsDaemonInstanceLock.name( + sid: sid, + supportHash: "0123456789abcdef0123456789abcdef", + rendezvousHash: "0123456789abcdef0123456789abcdef")) + } + + func testRendezvousSecurityAcceptsOnlyProtectedSingleUserDescriptors() { + let sid = "S-1-5-21-100-200-300-400" + XCTAssertTrue( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:P(A;;FA;;;\(sid))", sid: sid)) + XCTAssertTrue( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:PAI(A;;FA;;;\(sid))", sid: sid)) + XCTAssertTrue( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:PAI(A;ID;FA;;;\(sid))", sid: sid)) + XCTAssertTrue( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:PARAI(A;OICIID;0x001f01ff;;;\(sid))", sid: sid)) + XCTAssertTrue( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:BAD:P(A;;FA;;;LA)", sid: "S-1-5-21-100-200-300-500")) + XCTAssertFalse( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:BAD:P(A;;FA;;;LA)", sid: sid)) + XCTAssertFalse( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:AI(A;;FA;;;\(sid))", sid: sid)) + XCTAssertFalse( + WindowsPipeSecurity.isPrivateFileDescriptor( + "O:\(sid)G:SYD:PAI(A;;FA;;;\(sid))(A;;FR;;;WD)", sid: sid)) + } + + func testRemoteBridgeWireStateRejectsNonLoopbackAndInvalidCapabilities() throws { + let state = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 1, + host: "0.0.0.0", + port: 45_678, + capability: String(repeating: "a", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + XCTAssertThrowsError(try state.validated(now: 1_700_000_100)) { error in + XCTAssertEqual(error as? RemoteBridgeWireState.ValidationError, .invalidHost) + } + + let invalid = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 1, + port: 45_678, + capability: String(repeating: "A", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + XCTAssertThrowsError(try invalid.validated(now: 1_700_000_100)) { error in + XCTAssertEqual( + error as? RemoteBridgeWireState.ValidationError, .invalidCapability) + } + } + + func testRemoteBridgeListenerBindsLoopbackAndUsesAHighEphemeralPort() throws { + let listener = try WindowsRemoteBridgeListener( + pipeName: "\\\\.\\pipe\\graphcode-test-\(UUID().uuidString)", + state: { nil }) + XCTAssertGreaterThan(listener.port, 0) + listener.start() + listener.stop() + } + + func testRemoteBridgeStateStoreUsesCompareAndMatchCleanup() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-bridge-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let state = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 1, + port: 45_678, + capability: String(repeating: "b", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + let next = RemoteBridgeWireState( + daemonInstanceID: state.daemonInstanceID, + generation: 2, + port: state.port, + capability: String(repeating: "c", count: 64), + issuedAt: 1_700_000_100, + expiresAt: 1_700_001_100) + let store = try WindowsRemoteBridgeStateStore( + url: root.appendingPathComponent("bridge.json")) + try store.write(state) + XCTAssertEqual(try store.read(), state) + XCTAssertFalse(try store.writeIfMatches(nil, next)) + XCTAssertTrue(try store.writeIfMatches(state, next)) + XCTAssertFalse(try store.removeIfMatches(state)) + XCTAssertTrue(try store.removeIfMatches(next)) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.url.path)) + } + + func testWindowsRemoteBridgeShutdownStopsAndReapsRetainedSSHSession() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-bridge-shutdown-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let systemRoot = ProcessInfo.processInfo.environment["SystemRoot"] ?? "C:\\Windows" + let powershell = URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32/WindowsPowerShell/v1.0/powershell.exe") + let process = Process() + process.executableURL = powershell + process.arguments = ["-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 60"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + let session = WindowsSSHForwardSession(process: process) + let driver = WindowsSSHForwardDriver( + opener: { _, _ in session }, + verifier: { _, _ in true }) + let bridge = try WindowsRemoteBridge( + supportDirectory: root, + pipeName: "\\\\.\\pipe\\graphcode-shutdown-\(UUID().uuidString)", + ssh: driver) + + _ = try await bridge.ensureForwarding(authority: "alice@posix.example") + XCTAssertTrue(process.isRunning) + await bridge.shutdown() + XCTAssertFalse(process.isRunning) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: WindowsRemoteBridge.stateURL( + authority: "alice@posix.example", supportDirectory: root + ).path)) + } + + func testWindowsRemoteBridgeGenerationSurvivesShutdownAndRestart() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-bridge-generation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let systemRoot = ProcessInfo.processInfo.environment["SystemRoot"] ?? "C:\\Windows" + let powershell = URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32/WindowsPowerShell/v1.0/powershell.exe") + + func driver() -> WindowsSSHForwardDriver { + WindowsSSHForwardDriver( + opener: { _, _ in + let process = Process() + process.executableURL = powershell + process.arguments = [ + "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 60", + ] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + return WindowsSSHForwardSession(process: process) + }, + verifier: { _, _ in true }) + } + + let first = try WindowsRemoteBridge( + supportDirectory: root, + pipeName: "\\\\.\\pipe\\graphcode-generation-\(UUID().uuidString)", + ttl: 2, + ssh: driver()) + let firstState = try await first.ensureForwarding(authority: "alice@posix.example") + let rotatedState = try await first.rotate(authority: "alice@posix.example") + XCTAssertEqual(rotatedState.generation, firstState.generation + 1) + + try await Task.sleep(for: .seconds(2.2)) + let expiredState = try await first.ensureForwarding(authority: "alice@posix.example") + XCTAssertEqual(expiredState.generation, rotatedState.generation + 1) + await first.shutdown() + + let generationURL = WindowsRemoteBridge.generationURL( + authority: "alice@posix.example", supportDirectory: root) + XCTAssertTrue(FileManager.default.fileExists(atPath: generationURL.path)) + XCTAssertEqual(try String(contentsOf: generationURL, encoding: .ascii), "3") + XCTAssertFalse( + FileManager.default.fileExists( + atPath: WindowsRemoteBridge.stateURL( + authority: "alice@posix.example", supportDirectory: root + ).path)) + + let second = try WindowsRemoteBridge( + supportDirectory: root, + pipeName: "\\\\.\\pipe\\graphcode-generation-restart-\(UUID().uuidString)", + ttl: 0.05, + ssh: driver()) + let secondState = try await second.ensureForwarding(authority: "alice@posix.example") + await second.shutdown() + + XCTAssertEqual(firstState.generation, 1) + XCTAssertEqual(rotatedState.generation, 2) + XCTAssertEqual(expiredState.generation, 3) + XCTAssertEqual(secondState.generation, 4) + XCTAssertGreaterThan(secondState.generation, expiredState.generation) + } + + func testWindowsProductionRemoteDeliveryUsesSSHAndSecureStateInput() throws { + let location = RemoteProjectLocation( + user: "alice", host: "posix.example", port: 2222, remotePath: "/srv/project") + let state = RemoteBridgeWireState( + daemonInstanceID: UUID(), + generation: 7, + port: 45_678, + capability: String(repeating: "d", count: 64), + issuedAt: 1_700_000_000, + expiresAt: 1_700_001_000) + + let transfer = try XCTUnwrap( + ZmxSessionLauncher.remoteBridgeStateTransfer(state, at: location)) + let invocation = transfer.invocation.joined(separator: " ") + XCTAssertTrue(invocation.contains("ssh")) + XCTAssertFalse(invocation.hasPrefix("/usr/bin/ssh")) + XCTAssertTrue(invocation.contains("BatchMode")) + XCTAssertTrue(invocation.contains("bridge-state.json")) + XCTAssertFalse(invocation.contains(state.capability)) + XCTAssertTrue( + String(data: transfer.input, encoding: .utf8)?.contains(state.capability) == true) + let installer = RemoteGraphAccess.bridgeStateInstallerScript( + length: transfer.input.count, + sha256: GraphcodeSHA256.hex(transfer.input)) + XCTAssertTrue(installer.contains("fcntl.flock")) + XCTAssertTrue(installer.contains("current_generation < incoming_generation")) + XCTAssertTrue(installer.contains("os.replace")) + + let delivery = try XCTUnwrap( + ZmxSessionLauncher.remoteDeliveryScript( + forNode: nil, at: location, settings: GraphcodeSettings(), bridgeState: state)) + XCTAssertTrue(delivery.contains("graphcode")) + XCTAssertFalse(delivery.contains(state.capability)) + } + + func testRemoteBridgePublicationGateCancelsHungPredecessorForNewerGeneration() async { + let gate = WindowsRemoteBridgePublicationGate() + let first = Task { + await gate.publish( + authority: "alice@posix.example", + generation: 1, + timeout: .milliseconds(50) + ) { + try? await Task.sleep(for: .seconds(60)) + return !Task.isCancelled + } + } + try? await Task.sleep(for: .milliseconds(10)) + + let started = Date() + let second = await gate.publish( + authority: "alice@posix.example", + generation: 2, + timeout: .milliseconds(50) + ) { + true + } + + XCTAssertTrue(second) + XCTAssertLessThan(Date().timeIntervalSince(started), 1) + _ = await first.value + } + + func testRemoteBridgeTransferTimeoutTerminatesHungSSHProcess() async throws { + let systemRoot = ProcessInfo.processInfo.environment["SystemRoot"] ?? "C:\\Windows" + let powershell = URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32/WindowsPowerShell/v1.0/powershell.exe") + let started = Date() + let succeeded = await ZmxSessionLauncher.runRemoteRetrying( + [powershell.path, "-NoLogo", "-NoProfile", "-Command", "Start-Sleep -Seconds 60"], + attempts: 1, + timeout: .milliseconds(100)) + + XCTAssertFalse(succeeded) + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + } + + func testStructuredSSHAuthorityPreservesIPv6UserPortAndVerification() throws { + let authority = WindowsSSHAuthority(user: "alice", host: "::1", port: 2200) + XCTAssertEqual(authority.destination, "alice@[::1]") + XCTAssertEqual(authority.key, "alice@[::1]:2200") + XCTAssertEqual( + WindowsSSHForwardDriver.commonArguments(for: authority), + ["-p", "2200", "alice@[::1]"]) + XCTAssertEqual( + WindowsSSHAuthority(authority: "alice@[::1]:2200"), + authority) + + let driver = WindowsSSHForwardDriver( + opener: { _, _ in nil }, + verifier: { received, port in + XCTAssertEqual(received, authority) + XCTAssertEqual(port, 45_678) + return true + }) + XCTAssertTrue(try driver.verify(authority: authority, port: 45_678)) + } + + func testSSHForwardArgumentsPutEveryOptionBeforeHostnameOrIPv6Destination() { + let hostname = WindowsSSHForwardDriver.forwardingArguments( + for: WindowsSSHAuthority(host: "example.test"), port: 45_678) + XCTAssertEqual( + hostname, + [ + "-o", "StrictHostKeyChecking=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "GatewayPorts=no", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", + "-N", + "-R", "127.0.0.1:45678:127.0.0.1:45678", + "example.test", + ]) + + let ipv6 = WindowsSSHForwardDriver.forwardingArguments( + for: WindowsSSHAuthority(user: "alice", host: "::1", port: 2200), port: 45_678) + XCTAssertEqual( + ipv6, + [ + "-o", "StrictHostKeyChecking=yes", + "-o", "ExitOnForwardFailure=yes", + "-o", "GatewayPorts=no", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", + "-N", + "-R", "127.0.0.1:45678:127.0.0.1:45678", + "-p", "2200", + "alice@[::1]", + ]) + } + + func testSSHVerificationArgumentsPutPythonCommandAfterDestination() { + let arguments = WindowsSSHForwardDriver.verificationArguments( + for: WindowsSSHAuthority(user: "alice", host: "::1", port: 2200), + port: 45_678, + python: "print('loopback')") + XCTAssertEqual( + arguments, + [ + "-o", "StrictHostKeyChecking=yes", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "-p", "2200", + "alice@[::1]", + "python3", "-c", "print('loopback')", "45678", + ]) + } + + func testOpenSSHParsesForwardOptionsBeforeDestinationWithoutRemoteCommand() async throws { + guard let executable = SSHExecutableResolver.executableURL() else { + throw XCTSkip("Windows OpenSSH is not installed") + } + let arguments = + ["-G"] + + WindowsSSHForwardDriver.forwardingArguments( + for: WindowsSSHAuthority(user: "alice", host: "::1", port: 2200), port: 45_678) + let result = try await FoundationProcessRunner().run( + ProcessRequest(executable: executable, arguments: arguments), + timeout: .seconds(5)) + XCTAssertEqual(result.exitCode, 0) + let output = String(decoding: result.standardOutput, as: UTF8.self) + XCTAssertTrue(output.contains("user alice")) + XCTAssertTrue(output.contains("hostname ::1")) + XCTAssertTrue(output.contains("port 2200")) + } + + func testWindowsDaemonProjectRegistryUsesProductionRemoteEnsureCallback() async throws { + let fakeBridge = RecordingWindowsRemoteBridge() + let productionBridge = try? WindowsRemoteBridge() + ZmxSessionLauncher.setWindowsRemoteBridgeForTesting(fakeBridge) + defer { ZmxSessionLauncher.setWindowsRemoteBridgeForTesting(productionBridge) } + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-daemon-remote-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let registry = ProjectRegistry(persistenceDirectory: root) + let connection = RecordingDaemonConnection() + let connectionID = connection.id + let projectPath = "ssh://alice@[::1]:2200/work/remote-project" + await registry.addConnection(id: connectionID, connection: connection) + await registry.handle(.openProject(path: projectPath), connectionID: connectionID) + await registry.handle( + .graphCommand( + projectPath: projectPath, + command: .createNode( + NodeDraft( + title: "Remote", + loopType: .goalBased, + goal: GoalSpec(summary: "remote ensure"), + backend: .claudeCode))), + connectionID: connectionID) + + for _ in 0..<50 { + if !(await fakeBridge.authorities().isEmpty) { break } + try await Task.sleep(for: .milliseconds(20)) + } + let authorities = await fakeBridge.authorities() + XCTAssertEqual( + authorities, + [WindowsSSHAuthority(user: "alice", host: "::1", port: 2200)]) + } + + func testWindowsScheduledTaskLauncherPreservesCustomSupportDirectory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-task-env-\(UUID().uuidString)", isDirectory: true) + let support = root.appendingPathComponent("custom support", isDirectory: true) + let bin = support.appendingPathComponent("bin", isDirectory: true) + let daemon = bin.appendingPathComponent("probe.cmd") + let marker = root.appendingPathComponent("support-dir.txt") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let markerPath = marker.path.replacingOccurrences(of: "/", with: "\\") + try """ + @echo off + >"\(markerPath)" echo %GRAPHCODE_SUPPORT_DIR% + """.replacingOccurrences(of: "\n", with: "\r\n") + .write(to: daemon, atomically: true, encoding: .utf8) + + let recorder = RecordingProcessRunner() + let manager = try WindowsStartupManager( + daemonURL: daemon, + supportDirectory: support, + runner: recorder) + try await manager.installAndStart() + XCTAssertTrue(FileManager.default.fileExists(atPath: manager.launcherURL.path)) + XCTAssertTrue(manager.launcherIsCurrent()) + let requests = await recorder.requests + XCTAssertEqual(requests.count, 2) + let createCommand = requests[0].arguments.joined(separator: " ") + .replacingOccurrences(of: "/", with: "\\") + .lowercased() + XCTAssertTrue(createCommand.contains("\\xml")) + XCTAssertTrue( + createCommand.contains( + manager.taskDefinitionURL.path + .replacingOccurrences(of: "/", with: "\\") + .lowercased())) + XCTAssertFalse(createCommand.contains("-encodedcommand")) + let taskXML = try String(contentsOf: manager.taskDefinitionURL, encoding: .utf16) + XCTAssertTrue(taskXML.contains("")) + XCTAssertTrue(taskXML.contains("-File")) + + let shell = WindowsShellStrategy() + let invocation = try shell.invocation( + executable: manager.launcherURL, + arguments: [], + workingDirectory: nil, + environment: [:]) + let result = try await FoundationProcessRunner().run(invocation.request) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + try String(contentsOf: marker, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\", with: "/"), + support.path.replacingOccurrences(of: "\\", with: "/")) + XCTAssertFalse( + WindowsStartupManager.launcherContents( + daemonURL: daemon, + supportDirectory: support + ) + .contains("GRAPHCODE_SOCKET")) + } + + func testWindowsScheduledTaskXMLRegistersLongUnicodeSupportAndPipe() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "graphcode-task-xml-\(String(repeating: "long-", count: 7))测试-\(UUID().uuidString)", + isDirectory: true) + let support = root.appendingPathComponent( + String(repeating: "support-目录-", count: 4), + isDirectory: true) + let bin = support.appendingPathComponent("bin", isDirectory: true) + let daemon = bin.appendingPathComponent("graphcoded.cmd") + let requestedPipe = + "\\\\.\\PIPE\\GraphCode-Long-\(String(repeating: "x", count: 80))" + let environment = [ + SupportDirectory.environmentKey: support.path, + DaemonSocketPath.environmentKey: requestedPipe, + ] + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + try "@echo off\r\nexit /b 0\r\n" + .write(to: daemon, atomically: true, encoding: .utf8) + + let manager = try WindowsStartupManager( + daemonURL: daemon, + supportDirectory: support, + environment: environment, + runner: FoundationProcessRunner()) + var installed = false + do { + try await manager.installAndStart() + installed = true + let taskXML = try String(contentsOf: manager.taskDefinitionURL, encoding: .utf16) + XCTAssertTrue(taskXML.contains("")) + XCTAssertTrue(taskXML.contains("-File")) + XCTAssertTrue( + taskXML.contains(support.path.replacingOccurrences(of: "/", with: "\\"))) + let launcher = try String(contentsOf: manager.launcherURL, encoding: .utf16) + XCTAssertTrue( + launcher.contains( + try XCTUnwrap( + WindowsNamedPipeEndpoint.normalizedPipeName(environment: environment)))) + XCTAssertLessThan( + manager.taskDefinitionURL.path.utf16.count, + 260, + "the XML file path must remain schedulable") + + let systemRoot = + ProcessInfo.processInfo.environment["SystemRoot"] + ?? ProcessInfo.processInfo.environment["WINDIR"] + ?? "C:\\Windows" + let query = try await FoundationProcessRunner().run( + ProcessRequest( + executable: URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("schtasks.exe"), + arguments: ["/Query", "/TN", manager.taskName, "/FO", "LIST"])) + XCTAssertEqual(query.exitCode, 0) + } catch { + if installed { + try? await manager.stopAndUninstall() + } else { + try? await manager.uninstall() + } + throw error + } + try await manager.stopAndUninstall() + } + + func testWindowsScheduledTaskLauncherPersistsCustomPipeAndReconnects() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-task-pipe-\(UUID().uuidString)", isDirectory: true) + let support = root.appendingPathComponent("custom support-测试", isDirectory: true) + let bin = support.appendingPathComponent("bin", isDirectory: true) + let daemon = bin.appendingPathComponent("probe.cmd") + let marker = root.appendingPathComponent("environment.txt") + let requestedPipe = "\\\\.\\PIPE\\GraphCode-Custom-\(UUID().uuidString)" + let environment = [ + SupportDirectory.environmentKey: support.path, + DaemonSocketPath.environmentKey: requestedPipe, + ] + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + let markerPath = marker.path.replacingOccurrences(of: "/", with: "\\") + try """ + @echo off + >"\(markerPath)" echo %GRAPHCODE_SUPPORT_DIR% + >>"\(markerPath)" echo %GRAPHCODE_SOCKET% + """.replacingOccurrences(of: "\n", with: "\r\n") + .write(to: daemon, atomically: true, encoding: .utf8) + + let normalizedPipe = try XCTUnwrap( + WindowsNamedPipeEndpoint.normalizedPipeName(environment: environment)) + let manager = try WindowsStartupManager( + daemonURL: daemon, + supportDirectory: support, + environment: environment, + runner: RecordingProcessRunner()) + try await manager.installAndStart() + XCTAssertTrue(manager.launcherIsCurrent()) + let launcherData = try Data(contentsOf: manager.launcherURL) + XCTAssertEqual(Array(launcherData.prefix(2)), [0xFF, 0xFE]) + let launcher = try String(contentsOf: manager.launcherURL, encoding: .utf16) + XCTAssertTrue( + launcher.contains("$env:GRAPHCODE_SOCKET = '\(normalizedPipe)'")) + + let shell = WindowsShellStrategy() + let invocation = try shell.invocation( + executable: manager.launcherURL, + arguments: [], + workingDirectory: nil, + environment: [:]) + let result = try await FoundationProcessRunner().run(invocation.request) + XCTAssertEqual(result.exitCode, 0) + let environmentLines = try String(contentsOf: marker, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map(String.init) + XCTAssertEqual(environmentLines.count, 2) + XCTAssertEqual( + environmentLines[0].replacingOccurrences(of: "\\", with: "/").lowercased(), + support.path.replacingOccurrences(of: "\\", with: "/").lowercased()) + XCTAssertEqual(environmentLines[1], normalizedPipe) + + let listener = try WindowsNamedPipeListener(pipeName: normalizedPipe) + defer { Task { try? await listener.close() } } + let server = Task { + let connection = try await listener.accept() + let payload = try await connection.receiveFrame() + try await connection.sendFrame(payload) + try await connection.close() + return payload + } + let client = try WindowsNamedPipeClient.connect( + to: try WindowsNamedPipeEndpoint.name(environment: environment)) + let payload = Data("custom-pipe-reconnect".utf8) + try await client.sendFrame(payload) + let received = try await client.receiveFrame() + XCTAssertEqual(received, payload) + try await client.close() + let serverPayload = try await server.value + XCTAssertEqual(serverPayload, payload) + } + + func testWindowsCustomPipeOverrideGenerationRotatesAndRefreshesLauncher() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-custom-rotation-\(UUID().uuidString)", isDirectory: true) + let support = root.appendingPathComponent("support", isDirectory: true) + let daemon = support.appendingPathComponent("graphcoded.exe") + let firstEnvironment = [ + SupportDirectory.environmentKey: support.path, + DaemonSocketPath.environmentKey: "\\\\.\\pipe\\GraphCode-Rotation-A", + ] + let secondEnvironment = [ + SupportDirectory.environmentKey: support.path, + DaemonSocketPath.environmentKey: " \\\\.\\PIPE\\GraphCode-Rotation-B ", + ] + defer { try? FileManager.default.removeItem(at: root) } + + let firstPipe = try XCTUnwrap( + WindowsNamedPipeEndpoint.normalizedPipeName(environment: firstEnvironment)) + let secondPipe = try XCTUnwrap( + WindowsNamedPipeEndpoint.normalizedPipeName(environment: secondEnvironment)) + let firstGeneration = try WindowsNamedPipeEndpoint.generation( + environment: firstEnvironment) + let secondGeneration = try WindowsNamedPipeEndpoint.generation( + environment: secondEnvironment) + XCTAssertEqual(firstPipe, "\\\\.\\pipe\\graphcode-rotation-a") + XCTAssertEqual(secondPipe, "\\\\.\\pipe\\graphcode-rotation-b") + XCTAssertEqual( + firstGeneration, + GraphcodeSHA256.hex(Data(firstPipe.utf8))) + XCTAssertEqual( + secondGeneration, + GraphcodeSHA256.hex(Data(secondPipe.utf8))) + XCTAssertNotEqual(firstGeneration, secondGeneration) + XCTAssertFalse( + DaemonBootstrap.endpointGenerationIsCurrent( + current: secondGeneration, installed: firstGeneration)) + + let firstManager = try WindowsStartupManager( + daemonURL: daemon, + supportDirectory: support, + environment: firstEnvironment, + runner: RecordingProcessRunner()) + try await firstManager.installAndStart() + let secondManager = try WindowsStartupManager( + daemonURL: daemon, + supportDirectory: support, + environment: secondEnvironment, + runner: RecordingProcessRunner()) + XCTAssertEqual(firstManager.taskName, secondManager.taskName) + XCTAssertFalse(secondManager.launcherIsCurrent()) + try await secondManager.installAndStart() + XCTAssertTrue(secondManager.launcherIsCurrent()) + } + + func testWindowsNamedPipeWaitTimeoutIsRetryable() { + XCTAssertTrue( + WindowsNamedPipeClient.isRetryableWaitCode( + UInt32(truncatingIfNeeded: ERROR_SEM_TIMEOUT))) + XCTAssertTrue( + WindowsNamedPipeClient.isRetryableWaitCode( + UInt32(truncatingIfNeeded: ERROR_FILE_NOT_FOUND))) + XCTAssertTrue( + WindowsNamedPipeClient.isRetryableWaitCode( + UInt32(truncatingIfNeeded: ERROR_PIPE_BUSY))) + XCTAssertFalse( + WindowsNamedPipeClient.isRetryableWaitCode( + UInt32(truncatingIfNeeded: ERROR_ACCESS_DENIED))) + } + + func testWindowsCustomPipeOverrideRejectsInvalidValues() { + let invalidValues = [ + "not-a-named-pipe", + "\\\\.\\pipe\\", + "\\\\.\\pipe\\contains/slash", + "\\\\.\\pipe\\contains\"quote", + ] + for value in invalidValues { + let environment = [DaemonSocketPath.environmentKey: value] + XCTAssertThrowsError( + try WindowsNamedPipeEndpoint.name(environment: environment) + ) { error in + XCTAssertEqual(error as? WindowsPipeError, .invalidPipeName) + } + XCTAssertThrowsError( + try WindowsNamedPipeEndpoint.generation(environment: environment) + ) { error in + XCTAssertEqual(error as? WindowsPipeError, .invalidPipeName) + } + } + + } + + func testWindowsCustomPipeOverrideValidatesFullPipePathLength() throws { + let prefixLength = "\\\\.\\pipe\\".utf16.count + let tooLongSuffix = String(repeating: "a", count: 256 - prefixLength + 1) + let environment = [ + DaemonSocketPath.environmentKey: "\\\\.\\pipe\\\(tooLongSuffix)" + ] + XCTAssertThrowsError( + try WindowsNamedPipeEndpoint.name(environment: environment) + ) { error in + XCTAssertEqual(error as? WindowsPipeError, .invalidPipeName) + } + } + + func testFrameHeaderRemainsBoundedBeforeAllocation() throws { + XCTAssertThrowsError( + try DaemonFrameHeader.decodeLength( + [0x7f, 0xff, 0xff, 0xff], + maxPayloadBytes: DaemonFrameHeader.legacySafetyCeilingBytes)) + } + + func testPeerDisconnectCodesMapToConnectionClosed() { + let codes = [ + ERROR_BROKEN_PIPE, + ERROR_NO_DATA, + ERROR_PIPE_NOT_CONNECTED, + ERROR_OPERATION_ABORTED, + ERROR_CONNECTION_ABORTED, + ERROR_NETNAME_DELETED, + ] + for code in codes { + XCTAssertTrue( + WindowsPipeError.isPeerDisconnectCode(UInt32(truncatingIfNeeded: code)), + "code \(code) was not classified as a peer disconnect") + } + } + + func testWindowsPeerDisconnectUsesAmbiguousCLIExitCode() { + XCTAssertEqual(DaemonSocketClient.ambiguousExitCode, 75) + XCTAssertTrue( + DaemonSocketClient.isAmbiguousConnectionClose(WindowsPipeError.connectionClosed)) + XCTAssertFalse( + DaemonSocketClient.isAmbiguousConnectionClose(WindowsPipeError.timedOut)) + } + + func testWindowsTaskStateIgnoresLocalizedQueryText() { + let localizedFixture = "Estado: En ejecución\r\nEstado: Ejecutándose" + XCTAssertFalse(localizedFixture.isEmpty) + XCTAssertEqual( + WindowsStartupManager.status(taskQuerySucceeded: true, daemonProcessRunning: true), + .running) + XCTAssertEqual( + WindowsStartupManager.status(taskQuerySucceeded: true, daemonProcessRunning: false), + .stopped) + XCTAssertEqual( + WindowsStartupManager.status(taskQuerySucceeded: false, daemonProcessRunning: true), + .notInstalled) + } + + func testWindowsStopWaitsForDelayedProcessTermination() async throws { + let started = Date() + try await WindowsStartupManager.waitForExit(timeout: 1) { + Date().timeIntervalSince(started) < 0.2 + } + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(started), 0.2) + } + + func testWriteCancellationRaceClassifiesPossibleDeliveryAsAmbiguous() { + XCTAssertEqual( + WindowsPipeError.classifyWriteCancellation( + cancelSucceeded: true, + cancelError: nil, + completionSucceeded: false, + completionCode: UInt32(truncatingIfNeeded: ERROR_OPERATION_ABORTED), + transferred: 0), + .timedOut) + let ambiguous = WindowsPipeError.classifyWriteCancellation( + cancelSucceeded: false, + cancelError: UInt32(truncatingIfNeeded: ERROR_NOT_FOUND), + completionSucceeded: true, + completionCode: 0, + transferred: 1) + XCTAssertEqual(ambiguous, .writeOutcomeUnknown) + XCTAssertTrue(DaemonSocketClient.isAmbiguousConnectionClose(ambiguous)) + } + + func testManyIdlePreHandshakeClientsCannotExhaustWorkerPermits() { + let limiter = WindowsPipeHandshakeLimiter(limit: 4) + let permits = (0..<4).compactMap { _ in limiter.tryAcquire() } + XCTAssertEqual(permits.count, 4) + XCTAssertNil(limiter.tryAcquire()) + permits[0].release() + XCTAssertNotNil(limiter.tryAcquire()) + for permit in permits { + permit.release() + } + } + + func testManyIdlePreHandshakeClientsEventuallyAllowLegitimateClient() async throws { + let name = + try WindowsNamedPipeEndpoint.name() + + "-handshake-limit-\(UUID().uuidString.lowercased())" + let listener = try WindowsNamedPipeListener(pipeName: name) + defer { Task { try? await listener.close() } } + let limiter = WindowsPipeHandshakeLimiter(limit: 4) + let server = Task { + for _ in 0..<5 { + let connection = try await listener.accept() + guard let permit = limiter.tryAcquire() else { + try? await connection.close() + continue + } + Task { + defer { permit.release() } + do { + let frame = try await (connection as! WindowsNamedPipeConnection) + .receiveFrameWithFirstByteDeadline(firstByteTimeout: 0.15) + try await connection.sendFrame(frame) + } catch { + try? await connection.close() + } + } + } + } + let idleClients = try (0..<4).map { _ in + try WindowsNamedPipeClient.connect(to: name) + } + try await Task.sleep(for: .milliseconds(250)) + let legitimate = try WindowsNamedPipeClient.connect(to: name) + let payload = Data("legitimate".utf8) + try await legitimate.sendFrame(payload) + let response = try await legitimate.receiveFrame() + XCTAssertEqual(response, payload) + try await legitimate.close() + for client in idleClients { + try await client.close() + } + try await server.value + } + + func testManyAuthenticatedIdleClientsDoNotExhaustLegitimateService() async throws { + let name = + try WindowsNamedPipeEndpoint.name() + + "-established-idle-\(UUID().uuidString.lowercased())" + let listener = try WindowsNamedPipeListener(pipeName: name) + defer { Task { try? await listener.close() } } + + let idleCount = 64 + let hello = try JSONEncoder().encode( + DaemonWireEnvelope.hello(supportedVersions: [2], clientID: UUID())) + let helloResponse = try JSONEncoder().encode( + DaemonWireEnvelope.helloResponse(selectedVersion: 2)) + let server = Task { + for _ in 0...idleCount { + let connection = try await listener.accept() + Task { + defer { Task { try? await connection.close() } } + do { + let pipe = connection as! WindowsNamedPipeConnection + _ = try await pipe.receiveFrameWithFirstByteDeadline() + try await connection.sendFrame(helloResponse) + let frame = try await pipe.receiveFrameWithPostHandshakeDeadline(1) + try await connection.sendFrame(frame) + } catch { + try? await connection.close() + } + } + } + } + + var idleClients: [WindowsNamedPipeConnection] = [] + for _ in 0.. ProcessResult { + requests.append(request) + return ProcessResult(exitCode: 0, standardOutput: Data(), standardError: Data()) + } + } + + private actor RecordingWindowsRemoteBridge: WindowsRemoteBridgeService { + private var recorded: [WindowsSSHAuthority] = [] + + func ensureForwarding(authority: WindowsSSHAuthority) async throws -> RemoteBridgeState { + recorded.append(authority) + throw ProbeError.reached + } + + func authorities() -> [WindowsSSHAuthority] { + recorded + } + + private enum ProbeError: Error { + case reached + } + } + + private final class RecordingDaemonConnection: @unchecked Sendable, DaemonConnection { + let id = Foundation.UUID() + let endpoint: DaemonEndpoint = .namedPipe("\\\\.\\pipe\\graphcode-test") + + func receiveFrame() async throws -> Data { + throw ConnectionError.closed + } + + func sendFrame(_ data: Data) async throws {} + + func close() async throws {} + + private enum ConnectionError: Error { + case closed + } + } +#endif