Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions GraphcodeKit/Sources/GraphExportBundle+ZIP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,17 @@ extension GraphExportBundle {

private static func createZipArchive(at destination: URL, from source: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
// `--norsrc`, not `--sequesterRsrc`: sequestering writes AppleDouble copies into a
// `__MACOSX/` shadow tree, which doubled the archive's file count with junk every
// other platform shows the user. Nothing in a bundle has resource forks worth
// keeping.
process.arguments = ["-c", "-k", "--norsrc", source.path, destination.path]
#if os(Windows)
process.executableURL = windowsTarURL()
process.arguments = ["-a", "-cf", destination.path, "-C", source.path, "."]
#else
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
// `--norsrc`, not `--sequesterRsrc`: sequestering writes AppleDouble copies into a
// `__MACOSX/` shadow tree, which doubled the archive's file count with junk every
// other platform shows the user. Nothing in a bundle has resource forks worth
// keeping.
process.arguments = ["-c", "-k", "--norsrc", source.path, destination.path]
#endif

try? FileManager.default.removeItem(at: destination)
try process.run()
Expand All @@ -204,8 +209,13 @@ extension GraphExportBundle {

private static func extractZipArchive(from source: URL, to destination: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-q", source.path, "-d", destination.path]
#if os(Windows)
process.executableURL = windowsTarURL()
process.arguments = ["-xf", source.path, "-C", destination.path]
#else
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-q", source.path, "-d", destination.path]
#endif

try process.run()
process.waitUntilExit()
Expand All @@ -215,6 +225,16 @@ extension GraphExportBundle {
}
}

#if os(Windows)
private static func windowsTarURL() -> URL {
let environment = ProcessInfo.processInfo.environment
let root = environment["SystemRoot"] ?? environment["WINDIR"] ?? "C:\\Windows"
return URL(fileURLWithPath: root)
.appendingPathComponent("System32", isDirectory: true)
.appendingPathComponent("tar.exe")
}
#endif

private func readmeMarkdown(for manifest: ExportManifest) -> String {
var lines: [String] = [
"# GraphCode Export Bundle",
Expand Down
17 changes: 13 additions & 4 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1208,7 +1208,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 1211 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
// The board's gate forwards like any other side effect: a loop inside a piloted
// composite is a real loop whose session got the standard briefing — teaching
// verbs the child store would refuse is exactly the incoherence the gate exists
Expand Down Expand Up @@ -2120,7 +2120,7 @@
let followUp = PendingFollowUp(id: UUID(), nodeID: nodeID, text: prompt, watchedPostID: nil)
pendingFollowUps.append(followUp)
goalFollowUps[nodeID] = followUp.id
await drainAndBroadcast()

Check warning on line 2123 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
}

/// Opening a resolved loop whose session was ended brings its conversation back. Panes
Expand Down Expand Up @@ -2660,7 +2660,7 @@
return
}
if node.loopType == .composite {
await runInSubGraph(nodeID, .restartSessions, broadcastErrors: false)

Check warning on line 2663 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
return
}
await restart([node])
Expand All @@ -2669,7 +2669,7 @@
private func restartSessions() async {
let live = graph.nodes.filter { !$0.isResolved }
for composite in live where composite.loopType == .composite {
await runInSubGraph(composite.id, .restartSessions, broadcastErrors: false)

Check warning on line 2672 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
}
await restart(live.filter { $0.loopType != .composite })
}
Expand Down Expand Up @@ -2725,7 +2725,7 @@
// 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(

Check warning on line 2728 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
node.id, .stopNode(child.id), broadcastErrors: false)
}
}
Expand Down Expand Up @@ -3139,7 +3139,7 @@
// the cycle may begin.
if edge.fireCount > 0 {
if let until = edge.cycleGuard?.effectiveUntil, let onEvaluatePredicate {
let workingDirectory = graph.nodes[id: edge.from]?.worktreeBinding?.worktreePath
let workingDirectory = graph.nodes[id: edge.from].flatMap(predicateWorkingDirectory)
let satisfied = await onEvaluatePredicate(
ShellPredicate(command: until, workingDirectory: workingDirectory))
// The condition holds, so the loop is done — stop without another pass.
Expand Down Expand Up @@ -3175,7 +3175,7 @@
let command = goal.effectiveMetricCommand, let onCaptureScript
else { return }
let output = await onCaptureScript(
ShellPredicate(command: command, workingDirectory: node.worktreeBinding?.worktreePath))
ShellPredicate(command: command, workingDirectory: predicateWorkingDirectory(for: node)))
guard let output, let value = MetricTrend.value(fromScriptOutput: output) else {
recordMemory(nodeID, "metric: not measured (command failed or printed no number)")
return
Expand Down Expand Up @@ -3249,7 +3249,7 @@
case .script(let command):
guard let onCaptureScript else { return nil }
return await onCaptureScript(
ShellPredicate(command: command, workingDirectory: source.worktreeBinding?.worktreePath))
ShellPredicate(command: command, workingDirectory: predicateWorkingDirectory(for: source)))
}
}

Expand Down Expand Up @@ -3830,7 +3830,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 3833 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
onMailroomEnabled: onMailroomEnabled,
goalCache: goalCache,
recurrence: effects.recurrence,
Expand All @@ -3846,7 +3846,7 @@
processRecurrence(effects.recurrence)
graph.nodes[id: ownerID]?.subGraph = await child.graph
rollUpComposite(ownerID)
await drainAndBroadcast()

Check warning on line 3849 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
}

/// Applies the recurrence requests a child store handed up, in order — an update's
Expand Down Expand Up @@ -3917,14 +3917,14 @@
now.timeIntervalSince(node.createdAt) >= stallAfter
{
markStalled(nodeID)
await drainAndBroadcast()

Check warning on line 3920 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
return
}

// The budget is checked before the predicate for the same reason the stall bound
// is: a loop that has blown its bound gets no further evaluations spent on it.
if await enforceTokenBudget(nodeID, goal: goal) {
await drainAndBroadcast()

Check warning on line 3927 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:)' is unused
return
}

Expand All @@ -3949,7 +3949,7 @@
return
}
let shellPredicate = ShellPredicate(
command: predicate, workingDirectory: node.worktreeBinding?.worktreePath)
command: predicate, workingDirectory: predicateWorkingDirectory(for: node))

var fingerprint: String?
if goal.skipsUnchangedWorkspace, !forcePredicate, let onCaptureScript {
Expand Down Expand Up @@ -3983,6 +3983,7 @@
goalCache.setReawakened(fingerprint, for: nodeID)
goalCache.clearFeedback(for: nodeID)
}

}

let outcome: PredicateOutcome
Expand All @@ -4008,6 +4009,14 @@
await relayPredicateFailure(to: current, predicate: predicate, outcome: outcome)
}

/// Imported loops deliberately drop machine-specific worktree bindings. Local
/// predicates still belong to the graph's project, not the daemon's launch folder.
private func predicateWorkingDirectory(for node: LoopNode) -> String? {
if let worktreePath = node.worktreeBinding?.worktreePath { return worktreePath }
guard RemoteProjectLocation.parse(projectPath: graph.project.path) == nil else { return nil }
return graph.project.path
}

/// A verdict counts only for the goal it was recorded against. One dated before the goal
/// was last replaced belongs to the earlier goal; an undated one is trusted only while the
/// goal has never been replaced.
Expand Down
5 changes: 1 addition & 4 deletions GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1034,11 +1034,8 @@ import Foundation
_ 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 {
Thread.detachNewThread {
do {
continuation.resume(
returning: try self.receiveFrameWithPostHandshakeDeadlineSynchronously(timeout))
Expand Down
22 changes: 17 additions & 5 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,10 +291,21 @@ public actor ProjectRegistry {
/// labels at once (`list --where k=v` is in its help but returns every session whatever
/// you filter on, so it cannot be used to batch this).
///
/// Fifteen seconds puts a handful of millisecond-long socket round-trips a minute
/// against a canvas that tells the truth within a glance. It is the one number to turn
/// up if a graph ever grows to hundreds of loops.
/// Fifteen seconds keeps active work current. Once every loaded graph has no running
/// loops, the poll backs off to a minute: terminal input can still wake a parked session
/// without making a canvas full of stalled or completed loops expensive to leave open.
static let presencePollInterval: Duration = .seconds(15)
static let idlePresencePollInterval: Duration = .seconds(60)

static func presencePollDelay(runningLoops: Int) -> Duration {
runningLoops > 0 ? presencePollInterval : idlePresencePollInterval
}

private func presencePollDelay() async -> Duration {
var running = 0
for store in stores.values { running += await store.runningLoopCount() }
return Self.presencePollDelay(runningLoops: running)
}

/// Polling runs only while a client is attached — see `GraphStore.pollPresence` for why
/// the same guard is repeated per store. Started by the first connection and cancelled
Expand All @@ -303,9 +314,10 @@ public actor ProjectRegistry {
guard presencePoller == nil, readPresence != nil else { return }
presencePoller = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: Self.presencePollInterval)
guard let self else { return }
try? await Task.sleep(for: await self.presencePollDelay())
guard !Task.isCancelled else { return }
await self?.pollPresence()
await self.pollPresence()
}
}
}
Expand Down
65 changes: 50 additions & 15 deletions GraphcodeKit/Sources/Sessions/ProviderPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ public enum ProviderPath {
/// from `~/.zshrc`. `whence -p` rather than `command -v`: the launch `exec`s the agent,
/// which only a file on PATH satisfies, so an alias of the same name must not count.
public static func probeInvocation(for executable: String) -> [String] {
[
"/bin/zsh", "-i", "-l", "-c",
"whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1",
]
#if os(Windows)
let systemRoot = ProcessInfo.processInfo.environment["SystemRoot"] ?? "C:\\Windows"
return [
URL(fileURLWithPath: systemRoot)
.appendingPathComponent("System32")
.appendingPathComponent("where.exe").path,
executable,
]
#else
return [
"/bin/zsh", "-i", "-l", "-c",
"whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1",
]
#endif
}

/// `nil` when the shell did not answer in time: a slow `~/.zshrc` says nothing about
Expand All @@ -25,17 +35,42 @@ public enum ProviderPath {
{
if await FoundCache.shared.isFresh(executable) { return true }
let invocation = probeInvocation(for: executable)
guard
let shell = invocation.first,
let session = try? PTYProcessSession(
executable: shell, arguments: Array(invocation.dropFirst()))
else { return nil }
guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else {
session.terminate()
return nil
}
if found { await FoundCache.shared.record(executable) }
return found
#if os(Windows)
let process = Process()
process.executableURL = URL(fileURLWithPath: invocation[0])
process.arguments = Array(invocation.dropFirst())
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
let found = await withDeadline(deadline) {
await Task.detached {
process.waitUntilExit()
return process.terminationStatus == 0
}.value
}
guard let found else {
if process.isRunning { process.terminate() }
return nil
}
if found { await FoundCache.shared.record(executable) }
return found
#else
guard
let shell = invocation.first,
let session = try? PTYProcessSession(
executable: shell, arguments: Array(invocation.dropFirst()))
else { return nil }
guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else {
session.terminate()
return nil
}
if found { await FoundCache.shared.record(executable) }
return found
#endif
}

/// The failure launching `node` would hit, or `nil`. Always `nil` for a remote project:
Expand Down
22 changes: 20 additions & 2 deletions GraphcodeKit/Sources/Sessions/ShellPredicateEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,26 @@ public enum ShellPredicateEvaluator {
guard !trimmed.isEmpty else { return nil }

let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-l", "-c", "eval \"$GRAPHCODE_PREDICATE\""]
#if os(Windows)
process.executableURL = WindowsShellStrategy().powerShell
process.arguments = [
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
"""
$global:LASTEXITCODE = 0
try {
Invoke-Expression $env:GRAPHCODE_PREDICATE
if (-not $?) { exit 1 }
exit $global:LASTEXITCODE
} catch {
[Console]::Error.WriteLine($_)
exit 1
}
""",
]
#else
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-l", "-c", "eval \"$GRAPHCODE_PREDICATE\""]
#endif
if let workingDirectory = predicate.workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
}
Expand Down
6 changes: 5 additions & 1 deletion GraphcodeKit/Sources/Sessions/ZmxLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import Foundation
/// app's (often minimal, launchd-provided) `PATH`.
public enum ZmxLocator {
public static var binaryURL: URL {
SupportDirectory.binDirectory.appendingPathComponent("zmx")
#if os(Windows)
SupportDirectory.binDirectory.appendingPathComponent("zmx.exe")
#else
SupportDirectory.binDirectory.appendingPathComponent("zmx")
#endif
}

public static var isInstalled: Bool {
Expand Down
62 changes: 45 additions & 17 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,26 @@ public enum ZmxSessionLauncher {

static func loginShellInvocation(
of command: String, arguments: [String], environment: [String: String] = [:],
scriptSuffix: String = ""
scriptSuffix: String = "", usesWindowsShell: Bool = false
) -> [String] {
#if os(Windows)
if usesWindowsShell {
let environmentPrefix = environment.keys.sorted().flatMap { key in
let value = environment[key] ?? ""
let inheritedPrefix = "${\(key):+$\(key),}"
let windowsValue: String
if value.hasPrefix(inheritedPrefix) {
let suffix = String(value.dropFirst(inheritedPrefix.count))
windowsValue =
ProcessInfo.processInfo.environment[key].map { "\($0),\(suffix)" } ?? suffix
} else {
windowsValue = value
}
return ["set", "\(key)=\(windowsValue)", "&&"]
}
return environmentPrefix + [command] + arguments
}
#endif
// `env K=V …` rather than exporting: it scopes the variables to this one process, and
// keeps the script a single `exec` so the shell doesn't linger as a parent. Values are
// double-quoted because a project path can contain spaces; the whole script is one
Expand Down Expand Up @@ -1293,7 +1311,7 @@ public enum ZmxSessionLauncher {
environment: Self.environment(
forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile,
remoteHooksPath: remoteEnvironmentPath),
scriptSuffix: remoteHooksSuffix)
scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil)

// `zmx` types this command into the session's shell, and a tty in canonical mode
// discards everything past `MAX_CANON` (1024 bytes on macOS). Overrunning it does not
Expand Down Expand Up @@ -1324,7 +1342,7 @@ public enum ZmxSessionLauncher {
environment: Self.environment(
forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile,
remoteHooksPath: remoteEnvironmentPath),
scriptSuffix: remoteHooksSuffix)
scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil)
}
let unbriefedCommand = shed(prompt: promptWithMemory, briefingPath: nil, extraPath: nil)

Expand Down Expand Up @@ -1462,7 +1480,7 @@ public enum ZmxSessionLauncher {
forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil,
settings: settings),
hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath),
scriptSuffix: remoteHooksSuffix)
scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil)
}

/// Stands in for a remote session ID that this machine cannot know: the ID was written
Expand Down Expand Up @@ -2532,13 +2550,23 @@ public enum ZmxSessionLauncher {
) async {
let run = quotedCommand([zmxPath] + runArguments)
#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 = "\(checkCommand) >NUL 2>&1 || \(run)"
let executable = "cmd.exe"
let arguments = ["/d", "/s", "/c", script]
// Windows zmx receives argv directly. POSIX shell quoting turns paths and prompts
// into literal single-quoted text under cmd.exe, so launch the provider without a
// shell. zmx rejects a duplicate session atomically, preserving the check-or-create
// race guarantee without translating the POSIX readiness probe.
let process = Process()
process.executableURL = URL(fileURLWithPath: zmxPath)
process.arguments = runArguments
if let workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
}
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
await Task.detached { process.waitUntilExit() }.value
} catch {}
return
#else
// The stamp rides in the run branch, after the launch it describes and only if
// that launch was made. Repair precedes relaunch so an alive unlabelled session
Expand All @@ -2552,13 +2580,13 @@ public enum ZmxSessionLauncher {
?? "\(checkCommand) >/dev/null 2>&1 || \(repair)\(launch)"
let executable = "/bin/sh"
let arguments = ["-c", script]
guard
let session = try? PTYProcessSession(
executable: executable, arguments: arguments,
workingDirectory: workingDirectory)
else { return }
_ = await session.waitUntilFinished()
#endif
guard
let session = try? PTYProcessSession(
executable: executable, arguments: arguments,
workingDirectory: workingDirectory)
else { return }
_ = await session.waitUntilFinished()
}

}
Loading
Loading