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: 1 addition & 8 deletions GraphcodeKit/Sources/Domain/BackendCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,11 @@ extension CLISessionBackendKind {
// timer of its own. Copilot has since grown one. Worth knowing if a recurring loop
// runs once and stops: that is the symptom of a Copilot too old to have it, and
// `copilot help commands` on the machine running the loop is where to check.
//
// `supportsSubAgents` was false for the same reason and flipped the same way: read
// off 1.0.80's `copilot help commands`, which lists `/fleet` ("enable fleet mode for
// parallel subagent execution"), `/tasks` ("view and manage tasks (subagents and
// shell commands)") and `/subagents`, plus `--agent <agent>` on the launch line.
// That is the fan-out a composite leans on. The same age caveat applies: a
// composite whose Copilot workers never fan out is a Copilot older than that.
return BackendCapabilities(
supportsGoalMode: true,
supportsHooks: false,
supportsStructuredOutput: true,
supportsSubAgents: true,
supportsSubAgents: false,
supportsMCP: true,
supportsMidSessionInput: true,
supportsInSessionRecurrence: true)
Expand Down
9 changes: 0 additions & 9 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,15 +811,6 @@ public actor GraphStore {
goalCache: goalCache,
recurrence: effects.recurrence,
subGraphDepth: subGraphDepth + 1)
// A loop added inside a composite with no backend named runs on the composite's —
// a Copilot composite must produce Copilot workers, the same rule `createNode`
// applies to a loop fanning out from inside its own session. A creator the tree
// can find still wins, exactly as it would at the top level.
var command = command
if case .createNode(var draft) = command, draft.backend == nil {
draft.backend = draft.createdBy.flatMap { stored($0)?.backend } ?? node.backend
command = .createNode(draft)
}
await child.handle(command)
// Settled before the write-back and roll-up below, so a client sees the refusal
// ahead of the broadcast it would otherwise time out against, and an update's
Expand Down
151 changes: 142 additions & 9 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,12 @@ public actor ProjectRegistry {
send(.recentProjectsListed(persistence.loadRecentProjects()), to: fileDescriptor)

case .openProject(let path):
guard Self.isOpenable(path) else { break }
await open(Self.canonicalize(path), for: connectionID, fileDescriptor: fileDescriptor)
switch routing(for: path, isSidebar: sidebarConnections.contains(connectionID)) {
case .project(let canonicalPath):
await open(canonicalPath, for: connectionID, fileDescriptor: fileDescriptor)
case .refused(let reason):
send(.errorOccurred(reason), to: fileDescriptor)
}

case .restoreOpenProjects:
// Each of these broadcasts a `.graphChanged` exactly as `.openProject` would, so
Expand All @@ -294,7 +298,7 @@ public actor ProjectRegistry {
// it is joined to projects *other* clients open, so `graphcode status <new folder>`
// 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) {
for path in prunedOpenProjects() where Self.isWellFormedProjectPath(path) {
await open(path, for: connectionID, fileDescriptor: fileDescriptor)
}

Expand All @@ -308,6 +312,7 @@ public actor ProjectRegistry {
let canonicalPath = Self.canonicalize(path)
await close(canonicalPath, for: connectionID)
persistence.forgetProject(path: canonicalPath)
if path != canonicalPath { persistence.forgetProject(path: path) }

case .deleteProjectGraph(let path):
let canonicalPath = Self.canonicalize(path)
Expand All @@ -331,8 +336,20 @@ public actor ProjectRegistry {
persistence.deleteGraph(path: canonicalPath)

case .graphCommand(let path, let inner):
guard let store = stores[Self.canonicalize(path)] else { return }
await store.handle(inner)
// Routed the same way the open was, so a client that had its path redirected to the
// project containing it addresses that project here too. Without the second half,
// the open would land on one graph and every command after it on nothing at all —
// silently, which is how a `node create` could look like it hung.
switch routing(for: path, isSidebar: sidebarConnections.contains(connectionID)) {
case .project(let canonicalPath):
guard let store = stores[canonicalPath] else {
send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor)
return
}
await store.handle(inner)
case .refused(let reason):
send(.errorOccurred(reason), to: fileDescriptor)
}
}
}

Expand Down Expand Up @@ -378,7 +395,38 @@ public actor ProjectRegistry {
await store.removeConnection(connectionID)
}
connectionProjectPaths[connectionID]?.remove(canonicalPath)
persistence.saveOpenProjects(persistence.loadOpenProjects().filter { $0 != canonicalPath })
// Compared canonically, not literally: a project added before remote paths were
// normalized is stored under the spelling it arrived with, and closing it sends that
// spelling back through `canonicalize`. Filtering on the raw string left those rows
// in the open set and un-closable.
persistence.saveOpenProjects(
persistence.loadOpenProjects().filter { Self.canonicalize($0) != canonicalPath })
}

/// Clears out the empty twins a pre-normalization daemon left in the sidebar: a stored
/// path that is only another stored path spelled differently — a trailing slash, a
/// doubled separator — and whose graph never received a loop or a board post.
///
/// Deliberately timid. A twin with anything in it is left exactly where it is: it is
/// somebody's work, and folding it into the project it duplicates would make those
/// loops vanish rather than be found. Its graph file is kept either way; only the
/// sidebar entry and the recents row go, and re-opening the path brings both back.
private func prunedOpenProjects() -> [String] {
let stored = persistence.loadOpenProjects()
let kept = stored.filter { path in
let canonical = Self.canonicalize(path)
// Only ever a *later* twin, so the first spelling of a project always survives even
// when every stored spelling of it is a variant.
guard path != canonical,
stored.prefix(while: { $0 != path }).contains(where: { Self.canonicalize($0) == canonical })
else { return true }
let graph = persistence.loadGraph(path: path)
let isEmpty = (graph?.nodesAtAnyDepth.isEmpty ?? true) && (graph?.artifactory.isEmpty ?? true)
if isEmpty { persistence.forgetProject(path: path) }
return !isEmpty
}
if kept != stored { persistence.saveOpenProjects(kept) }
return kept
}

/// Append rather than insert-at-front: the sidebar should come back in the order it
Expand All @@ -397,6 +445,82 @@ public actor ProjectRegistry {
return true
}

// MARK: - Which project a named path belongs to

enum PathRouting: Equatable {
case project(String)
case refused(String)
}

/// Where a path a client named should be routed, and whether it may become a *new*
/// project rather than an existing one.
///
/// Opening is create-if-missing, because that is how a folder becomes a project at all:
/// `graphcode status <folder>` from a shell is a supported way to add one. What that
/// missed is that most paths a *loop* names are not new projects — they are its own
/// worktree, its working directory, or its project's path spelled slightly differently.
/// Each of those quietly became a second project: its own graph, its own recents entry,
/// its own row in the sidebar under the same name, with the loops the agent then created
/// inside it where nobody was looking. A codespace made it trivial to hit, since a
/// remote path is never checked against a filesystem: every spelling of one was openable.
///
/// So two kinds of path are never a new project when a shell client names them:
///
/// - **A folder inside a project that already exists** is that project — a worktree
/// under the repository, a subdirectory, the remote path of a codespace already added.
/// - **A remote path this daemon has never seen.** Remote projects are added in the app,
/// which validates the connection over ssh first; nothing typed at a shell can be
/// checked that way, so an unknown one is a typo or a spelling variant of a known one.
///
/// The app is exempt from both, and is told apart by having asked for the whole open set
/// (`sidebarConnections`): opening a nested folder or adding a remote host is a
/// deliberate human act there, and refusing it would break Add Folder.
func routing(for path: String, isSidebar: Bool) -> PathRouting {
guard Self.isWellFormedProjectPath(path) else {
return .refused(
"\(path) isn't a project path — name an absolute folder, an ssh:// or codespace:// "
+ "project, or \(LoopGraphScope.globalPath).")
}
let canonicalPath = Self.canonicalize(path)
guard canonicalPath != LoopGraphScope.globalPath else { return .project(canonicalPath) }
let known = knownProjectPaths()
if known.contains(canonicalPath) { return .project(canonicalPath) }
if !isSidebar, let container = Self.project(containing: canonicalPath, in: known) {
return .project(container)
}
if RemoteProjectLocation.parse(projectPath: canonicalPath) != nil {
guard isSidebar else {
return .refused(
"graphcode doesn't know a project at \(canonicalPath). Run `graphcode projects` "
+ "for the exact path; a remote repository or codespace is added in the app.")
}
return .project(canonicalPath)
}
guard Self.isOpenable(canonicalPath) else {
return .refused(
"there's no folder at \(canonicalPath). Run `graphcode projects` for the paths "
+ "graphcode knows.")
}
return .project(canonicalPath)
}

/// Every project this daemon knows about, canonically spelled: what the sidebar has
/// open, what recents remembers, and whatever is resident.
private func knownProjectPaths() -> Set<String> {
var paths = Set(persistence.loadOpenProjects().map(Self.canonicalize))
paths.formUnion(persistence.loadRecentProjects().map { Self.canonicalize($0.path) })
paths.formUnion(stores.keys)
return paths
}

/// The deepest known project a path lies inside — deepest so that a nested project a
/// human deliberately opened wins over the repository around it.
static func project(containing path: String, in known: Set<String>) -> String? {
known
.filter { $0 != LoopGraphScope.globalPath && path.hasPrefix($0 + "/") }
.max { $0.count < $1.count }
}

// MARK: - The global Orchestrator Graph

/// Loads the one always-resident global graph
Expand Down Expand Up @@ -551,9 +675,18 @@ public actor ProjectRegistry {
/// 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 {
guard path != LoopGraphScope.globalPath,
RemoteProjectLocation.parse(projectPath: path) == nil
else { return path }
guard path != LoopGraphScope.globalPath else { return path }
// A remote path gets the textual half of the same treatment. It cannot be resolved
// against this filesystem — the directory is on another machine — but the spellings
// that fork one project into two are all textual: a trailing slash, a doubled
// separator, a `.` segment. Left unnormalized, `codespace://cs/workspaces/repo/` and
// `codespace://cs/workspaces/repo` were two projects, two graphs, and two rows in the
// sidebar with the same name.
if let remote = RemoteProjectLocation.parse(projectPath: path) {
var normalized = remote
normalized.remotePath = RemoteProjectLocation.normalizedPath(remote.remotePath)
return normalized.projectPath
}
return URL(fileURLWithPath: path).resolvingSymlinksInPath().path
}

Expand Down
107 changes: 91 additions & 16 deletions GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,16 @@ public enum RemoteGraphAccess {

def open_project(self, path):
self.send({"openProject": {"path": path}})
_, value = self.wait_for(["graphChanged"])
key, value = self.wait_for(["graphChanged", "errorOccurred"])
if key == "errorOccurred":
fail(value["_0"])
return value["_0"]

def known_projects(self):
self.send({"listRecentProjects": {}})
_, value = self.wait_for(["recentProjectsListed"])
return [p.get("path") for p in (value["_0"] or []) if p.get("path")]


def self_node_id():
name = os.environ.get("ZMX_SESSION", "")
Expand Down Expand Up @@ -355,21 +362,90 @@ public enum RemoteGraphAccess {
return {"graphCommand": {"projectPath": project, "command": command}}


def run_and_print(project, commands):
def normalized(path):
parts = []
for segment in path.split("/"):
if not segment or segment == ".":
continue
if segment == "..":
if parts:
parts.pop()
continue
parts.append(segment)
return "/" + "/".join(parts)


def remote_parts(project):
# (host, path) for a project this daemon reaches over ssh, else (None, None).
for scheme in ("ssh://", "codespace://"):
if project.startswith(scheme):
rest = project[len(scheme):]
separator = rest.find("/")
if separator < 1:
return None, None
authority = rest[:separator].split("@")[-1].split(":")[0]
return authority, normalized(rest[separator:])
return None, None


def this_host_names():
names = [os.environ.get("CODESPACE_NAME", "")]
try:
names.append(os.uname().nodename)
except Exception:
pass
return [name for name in names if name]


def resolve_project(daemon, project):
# A path spelled the way *this host* sees it -- the session's working directory,
# its worktree -- named as the project it belongs to. The Mac keys graphs by the
# ssh:// or codespace:// URI, and opening one is create-if-missing, so a local
# spelling used to add a second project with the same name and put the loops in
# there rather than in the graph the human is watching.
if not project.startswith("/"):
return project
here = normalized(project)
matches = []
for known in daemon.known_projects():
host, path = remote_parts(known)
if path is None:
continue
if here == path or here.startswith(path.rstrip("/") + "/"):
matches.append((known, host))
if not matches:
return project
if len(matches) > 1:
local = this_host_names()
preferred = [match for match in matches if match[1] in local]
if len(preferred) != 1:
fail("%s is inside more than one project graphcode knows (%s). Name the "
"one you mean -- your briefing states it exactly."
% (project, ", ".join(sorted(match[0] for match in matches))))
matches = preferred
resolved = matches[0][0]
sys.stderr.write("graphcode: %s is this host's path for %s\n" % (project, resolved))
return resolved


def run_and_print(project, inner=None):
daemon = Daemon()
project = resolve_project(daemon, project)
graph = daemon.open_project(project)
for command in commands:
daemon.send(command)
if commands:
_, value = daemon.wait_for(["graphChanged"])
if inner is not None:
daemon.send(graph_command(project, inner))
key, value = daemon.wait_for(["graphChanged", "errorOccurred"])
if key == "errorOccurred":
fail(value["_0"])
graph = value["_0"]
print(render(graph))


def run_with_verdict(project, command, acknowledgement):
def run_with_verdict(project, inner, acknowledgement):
daemon = Daemon()
project = resolve_project(daemon, project)
daemon.open_project(project)
daemon.send(command)
daemon.send(graph_command(project, inner))
key, value = daemon.wait_for(["graphChanged", "errorOccurred"])
if key == "errorOccurred":
fail(value["_0"])
Expand Down Expand Up @@ -462,7 +538,7 @@ public enum RemoteGraphAccess {
if verb == "status":
if not arguments:
fail("missing project-path")
run_and_print(arguments[0], [])
run_and_print(arguments[0])
return
if verb != "node":
fail("unknown or Mac-only command: %s (see `graphcode help`)" % verb)
Expand All @@ -483,19 +559,19 @@ public enum RemoteGraphAccess {
if flags.get("into"):
into = parse_uuid(flags["into"], "--into")
create = {"subGraphCommand": {"nodeID": into, "command": create}}
run_and_print(project, [graph_command(project, create)])
run_and_print(project, create)
return
if subverb not in ("stop", "restart", "delete", "send", "memo"):
fail("node %s runs from the Mac's own shell, not from a remote host" % subverb)
if not arguments:
fail("missing node-id")
node_id = parse_uuid(arguments.pop(0), "node-id")
if subverb == "stop":
run_and_print(project, [graph_command(project, {"stopNode": {"_0": node_id}})])
run_and_print(project, {"stopNode": {"_0": node_id}})
elif subverb == "restart":
run_and_print(project, [graph_command(project, {"restartNode": {"_0": node_id}})])
run_and_print(project, {"restartNode": {"_0": node_id}})
elif subverb == "delete":
run_and_print(project, [graph_command(project, {"deleteNode": {"_0": node_id}})])
run_and_print(project, {"deleteNode": {"_0": node_id}})
else:
follow_up = False
if subverb == "send" and arguments and arguments[0] == "--follow-up":
Expand All @@ -511,12 +587,11 @@ public enum RemoteGraphAccess {
if subverb == "send":
if follow_up:
payload["followUp"] = True
run_with_verdict(project, graph_command(project, {"messageNode": payload}),
run_with_verdict(project, {"messageNode": payload},
"accepted — typed in when the loop next goes idle"
if follow_up else "delivered")
else:
run_with_verdict(project, graph_command(project, {"memoNode": payload}),
"noted")
run_with_verdict(project, {"memoNode": payload}, "noted")


main(sys.argv[1:])
Expand Down
Loading
Loading