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
9 changes: 4 additions & 5 deletions GraphcodeKit/Sources/Domain/BackendCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,10 @@ extension CLISessionBackendKind {
/// (`GhosttyTerminalView.command`), so a loop labelled Codex opened a Claude Code
/// session. Silently running a different agent than the one the picker says is worse
/// than refusing, so this now gates every loop type.
/// Every CLI, now that each has an adapter and a row read off its real binary. Nod is
/// the next backend the concept was kept for: its row is designed, but nothing can
/// launch it until NodRuntime ships, so it hosts nothing yet — except where a developer
/// points `NodRuntimeLocation.overrideVariable` at a runtime (or a fake of one).
public var isSpiked: Bool { self != .nod || NodRuntimeLocation.developmentOverride != nil }
/// Every CLI, now that each has an adapter and a row read off its real binary. Nod ships
/// inside the app, so it is launchable wherever its ramp is on and its runtime is
/// present (`NodRuntimeLocation.isAvailable`) — and hosts nothing anywhere else.
public var isSpiked: Bool { self != .nod || NodRuntimeLocation.isAvailable() }

/// Whether this backend can host that loop type at all. The refusal
/// docs/04-cli-backends.md asks `OrchestratorClient` to make, kept next to the
Expand Down
7 changes: 7 additions & 0 deletions GraphcodeKit/Sources/Domain/NodProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,13 @@ public enum NodRuntimeLocation {
/// launches the session: the app for a pane, `graphcoded` for an unattended loop.
public static let overrideVariable = "GRAPHCODE_NOD_PATH"

/// Whether this process can launch Nod. Domain cannot see the support directory or the
/// app bundle, so each process installs the real answer at startup
/// (`NodRuntimeLocator.installAvailability`); until then only the override counts.
nonisolated(unsafe) public static var isAvailable: @Sendable () -> Bool = {
developmentOverride != nil
}

/// The override, when it names an executable file.
public static var developmentOverride: String? {
guard let path = ProcessInfo.processInfo.environment[overrideVariable], !path.isEmpty,
Expand Down
13 changes: 12 additions & 1 deletion GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ public enum NodRuntimeLocator {
.appendingPathComponent("ramp.on")
}

/// The test host shares ~/.graphcode with whatever Nod this Mac has installed; its
/// answers must come from the overrides above, never from that install.
static let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil

/// Whether Nod may be created or launched at all on this install.
public static var isRampedOn: Bool {
rampOverride ?? FileManager.default.fileExists(atPath: rampFlag.path)
rampOverride ?? (!isTestHost && FileManager.default.fileExists(atPath: rampFlag.path))
}

/// The runtime's directory inside the app bundle — `graphcode-nod` with what it loads
Expand All @@ -46,9 +50,16 @@ public enum NodRuntimeLocator {
bundle.bundleURL.appendingPathComponent("\(bundledDirectory)/\(name)"),
SupportDirectory.binDirectory.appendingPathComponent("\(installedDirectory)/\(name)"),
]
guard !isTestHost else { return nil }
return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) }
}

/// Makes `CLISessionBackendKind.nod.isSpiked` mean "this process could launch it": the
/// ramp is on and a runtime is present. Called once by the app, the daemon and the CLI.
public static func installAvailability() {
NodRuntimeLocation.isAvailable = { binaryURL() != nil }
}

/// `$NOD_STATE` for a node — under the support directory, so a moved workspace keeps
/// its Nod state with everything else it owns.
public static func stateDirectory(forNodeID nodeID: UUID) -> URL {
Expand Down
14 changes: 8 additions & 6 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1532,7 +1532,7 @@ public enum ZmxSessionLauncher {
node.backend == .copilotCLI
? nil : SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
let resumeArgs =
nodArguments(forNode: node, projectPath: projectPath, settings: settings)
nodArguments(forNode: node, projectPath: projectPath, settings: settings, fresh: false)
+ node.backend.launchArguments(
prompt: nil, tier: tier, settings: settings,
workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath),
Expand Down Expand Up @@ -1566,18 +1566,20 @@ public enum ZmxSessionLauncher {
}

/// `nodArguments` for a launch from here: the node's working directory, and its goal
/// written where `--goal-file` points. Empty for every other backend. `inheritFile` is
/// for a fresh launch only — a resumed conversation already has what it inherited.
/// written where `--goal-file` points. Empty for every other backend. The lineage brief
/// rides a fresh launch only — a resumed conversation already has what it inherited.
/// A composite child is unattended like a timed loop: nobody is watching it to answer.
static func nodArguments(
forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings,
inheritFile: String? = nil
fresh: Bool = true
) -> [String] {
guard node.backend == .nod else { return [] }
return node.backend.nodArguments(
nodeID: node.id, loopType: node.loopType, settings: settings,
workingDirectory: workingDirectory(forNode: node, projectPath: projectPath),
goalFile: NodRuntimeLocator.writeGoal(of: node)?.path, inheritFile: inheritFile,
unattended: node.loopType == .timeBased)
goalFile: NodRuntimeLocator.writeGoal(of: node)?.path,
inheritFile: fresh ? node.lineage?.briefPath : nil,
unattended: node.loopType == .timeBased || node.lineage?.kind == .compositeChild)
}

/// A Nod session with nothing to say yet — a main loop with no starting note. Unlike a
Expand Down
20 changes: 18 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
dev-generate dev-build-app dev-build-daemon dev-install-daemon \
dev-install-zmx dev-run-app dev-status \
third-party build-zmx install-zmx build-ghostty vendor-sdk \
build-cli install-cli release-dmg notarize signing-doctor tap-bump
build-cli install-cli release-dmg notarize signing-doctor tap-bump build-nod

SCHEME_APP := graphcode
SCHEME_DAEMON := graphcoded
Expand Down Expand Up @@ -78,6 +78,14 @@ doctor:
# ---------------------------------------------------------------------------
third-party: build-zmx build-ghostty

# GraphCode Nod's runtime: graphcode-nod plus both engines' agent runtimes, the folder the
# app carries at Contents/Helpers/nod (NodRuntime/README.md). Needs bun.
NOD_BUILD_DIR := $(BUILD_DIR)/nod
build-nod:
@command -v bun >/dev/null || { echo "bun missing — brew install oven-sh/bun/bun"; exit 1; }
rm -rf "$(NOD_BUILD_DIR)"
cd NodRuntime && sh scripts/package.sh "$(NOD_BUILD_DIR)" bun-darwin-arm64

build-zmx:
@test -d ThirdParty/zmx || { echo "ThirdParty/zmx missing — run: git submodule update --init --recursive"; exit 1; }
@# -Doptimize is load-bearing: zig defaults to Debug, whose ghostty-vt runs
Expand Down Expand Up @@ -399,7 +407,7 @@ NOTARY_PROFILE ?= graphcode
# quarantines it. Never publish the output of a NOTARIZE=0 run.
NOTARIZE ?= 1

release-dmg: generate build-zmx
release-dmg: generate build-zmx build-nod
@set -e; \
for scheme in $(SCHEME_APP) $(SCHEME_DAEMON) $(SCHEME_CLI); do \
echo "building $$scheme (Release, arm64)"; \
Expand All @@ -418,6 +426,8 @@ release-dmg: generate build-zmx
cp "$$PRODUCTS/graphcoded" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/graphcoded"; \
cp "$$PRODUCTS/graphcode" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/graphcode"; \
cp "$(BUILD_DIR)/zmx/bin/zmx" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/zmx"; \
mkdir -p "$(DMG_STAGE)/graphcode.app/Contents/Helpers"; \
ditto "$(NOD_BUILD_DIR)" "$(DMG_STAGE)/graphcode.app/Contents/Helpers/nod"; \
APP="$(DMG_STAGE)/graphcode.app"; \
echo "signing the bundle (embedding helpers invalidates the outer signature)"; \
if [ -z "$(SIGN_ID)" ]; then \
Expand All @@ -432,6 +442,12 @@ release-dmg: generate build-zmx
--sign "$(SIGN_ID)" "$$APP/Contents/Resources/bin/$$helper" >/dev/null 2>&1 \
|| { echo "failed to sign helper $$helper"; exit 1; }; \
done; \
for helper in graphcode-nod copilot-runtime runtime.node; do \
codesign --force --options runtime --timestamp \
--entitlements NodRuntime/packaging/entitlements.plist \
--sign "$(SIGN_ID)" "$$APP/Contents/Helpers/nod/$$helper" >/dev/null 2>&1 \
|| { echo "failed to sign Nod's $$helper"; exit 1; }; \
done; \
if [ -d "$$APP/Contents/Frameworks" ]; then \
find "$$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -perm -u+x \) -print0 \
| xargs -0 -I{} codesign --force --options runtime --timestamp \
Expand Down
2 changes: 2 additions & 0 deletions graphcode-cli/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ func fail(_ message: String, code: Int32 = ExitCode.usage) -> Never {
exit(code)
}

NodRuntimeLocator.installAvailability()

let command: GraphcodeCommand
do {
command = try GraphcodeCommand.parse(Array(CommandLine.arguments.dropFirst()))
Expand Down
2 changes: 1 addition & 1 deletion graphcode/Sources/Features/Nod/NodChatFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ struct NodChatFeature {
branch: String? = nil, goal: String? = nil
) {
self.nodeID = nodeID
self.stateDirectory = stateDirectory ?? NodStateDirectory.url(forNode: nodeID)
self.stateDirectory = stateDirectory ?? NodRuntimeLocator.stateDirectory(forNodeID: nodeID)
self.loopTitle = loopTitle
self.loopType = loopType
self.branch = branch
Expand Down
8 changes: 0 additions & 8 deletions graphcode/Sources/Features/Nod/NodClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,6 @@ struct NodClient: Sendable {
var send: @Sendable (_ stateDirectory: URL, _ command: NodCommand) async throws -> Void
}

enum NodStateDirectory {
/// `$NOD_STATE` — `<support dir>/nod/<node-uuid>/`, which the runtime is launched with.
static func url(forNode id: UUID, supportDirectory: URL = SupportDirectory.url) -> URL {
supportDirectory.appendingPathComponent("nod", isDirectory: true)
.appendingPathComponent(id.uuidString, isDirectory: true)
}
}

/// Splits appended bytes into records, holding a torn final line back until the rest of
/// it arrives. Pure, so the tail's edge cases are testable without a file.
struct NodEventTail: Equatable {
Expand Down
7 changes: 7 additions & 0 deletions graphcode/Sources/GraphcodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ struct GraphcodeApp: App {
unsetenv("ZMX_SESSION")
AgentEnvironment.scrubInheritedAgentIdentity()
SupportDirectory.prepare()
// From the cached ramp, so a fresh install need not wait on the network fetch. Not as a
// test host: the suite shares ~/.graphcode with whatever Nod this Mac has installed, and
// its hosting assertions must not depend on that.
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
FeatureRamps.publishNodFlag(enabled: FeatureRamps.isEnabled(.nod))
NodRuntimeLocator.installAvailability()
}
let nodCards = NodLiveCardState()
NodCardWiring.provider = nodCards
NodCardWiring.answerer = nodCards
Expand Down
5 changes: 4 additions & 1 deletion graphcode/Tests/NodBackendTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ struct NodBackendTests {
/// Until NodRuntime ships a binary, a loop labelled Nod would open nothing — the exact
/// failure `isSpiked` exists to refuse.
@Test
func nodHostsNothingUntilTheRuntimeShips() {
func nodHostsNothingWhereItCannotLaunch() {
// The test host never installs `NodRuntimeLocator.installAvailability`, so this is the
// answer for a process with no ramp flag or runtime: only the override would count.
#expect(NodRuntimeLocation.developmentOverride == nil)
#expect(!CLISessionBackendKind.nod.isSpiked)
#expect(CLISessionBackendKind.nod.executableName == "graphcode-nod")
for loopType in LoopType.allCases {
Expand Down
23 changes: 21 additions & 2 deletions graphcode/Tests/NodLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,12 @@ struct NodLaunchArgumentTests {

@Test
func aTimedLoopIsUnattendedAndTheEnvironmentNamesItsProject() {
let timed = LoopNode(
var timed = LoopNode(
id: nodeID, title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop 1h deps",
backend: .nod)
timed.lineage = LoopLineage(kind: .fork, sourceNodeID: UUID(), briefPath: "/b.md")
let argv = ZmxSessionLauncher.nodArguments(
forNode: timed, projectPath: nil, settings: GraphcodeSettings(), inheritFile: "/b.md")
forNode: timed, projectPath: nil, settings: GraphcodeSettings())
#expect(Array(argv.suffix(3)) == ["--inherit", "/b.md", "--unattended"])
let goal = LoopNode(id: nodeID, title: "Cap", loopType: .turnBased, backend: .nod)
#expect(
Expand All @@ -298,6 +299,24 @@ struct NodLaunchArgumentTests {
"NOD_STATE": NodRuntimeLocator.stateDirectory(forNodeID: nodeID).path,
"NOD_NODE_ID": nodeID.uuidString, "NOD_PROJECT_PATH": "/p",
])
}

/// A brief is what a fresh conversation starts from; a resumed one already has it, and
/// sending it again would replay the handoff as a new turn.
@Test
func theLineageBriefRidesAFreshLaunchOnly() {
var child = LoopNode(id: nodeID, title: "Server caps", loopType: .goalBased, backend: .nod)
child.lineage = LoopLineage(
kind: .compositeChild, sourceNodeID: UUID(), briefPath: "/briefs/child.json")

let fresh = ZmxSessionLauncher.nodArguments(
forNode: child, projectPath: nil, settings: GraphcodeSettings())
let resumed = ZmxSessionLauncher.nodArguments(
forNode: child, projectPath: nil, settings: GraphcodeSettings(), fresh: false)

#expect(Array(fresh.suffix(3)) == ["--inherit", "/briefs/child.json", "--unattended"])
#expect(!resumed.contains("--inherit"))
#expect(resumed.last == "--unattended")
#expect(NodRuntimeLocator.environment(forNodeID: nodeID)["NOD_PROJECT_PATH"] == nil)
}

Expand Down
9 changes: 6 additions & 3 deletions graphcode/Tests/NodTranscriptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,16 @@ extension [NodTranscript.Item] {

@Suite
struct NodEventTailTests {
/// The runtime's launcher names the directory with `uuidString` as is; a lowercased copy
/// The pane reads the directory the launcher names: `uuidString` as is. A lowercased copy
/// would split the pane from the runtime on a case-sensitive volume.
@Test
func theStateDirectoryIsTheUppercaseNodeID() throws {
let id = try #require(UUID(uuidString: "9b3408f9-9b16-447f-a439-fc2aa8c02d06"))
let url = NodStateDirectory.url(forNode: id, supportDirectory: URL(fileURLWithPath: "/s"))
#expect(url.path == "/s/nod/9B3408F9-9B16-447F-A439-FC2AA8C02D06")
let url = NodRuntimeLocator.stateDirectory(forNodeID: id)
#expect(url.lastPathComponent == "9B3408F9-9B16-447F-A439-FC2AA8C02D06")
#expect(
url.deletingLastPathComponent().path
== SupportDirectory.url.appendingPathComponent("nod").path)
}

@Test
Expand Down
1 change: 1 addition & 0 deletions graphcoded/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ import GraphcodeKit
// directory. Has to happen before anything reads or writes — including the socket bind
// immediately below.
SupportDirectory.prepare()
NodRuntimeLocator.installAvailability()
let supportDirectory = SupportDirectory.url

let socketURL = DaemonSocketPath.url
Expand Down
Loading