diff --git a/agent-sidecar/src/spec-helpers.mjs b/agent-sidecar/src/spec-helpers.mjs index 44eb6d5..f083134 100644 --- a/agent-sidecar/src/spec-helpers.mjs +++ b/agent-sidecar/src/spec-helpers.mjs @@ -22,22 +22,40 @@ function grantedOf(grantedTools) { return Array.isArray(grantedTools) ? grantedTools.filter((t) => typeof t === "string") : []; } +/** + * The tool a permission RULE names — `"Bash"` for `"Bash(nxc reply:*)"`, the string itself for a + * plain tool name (nxf 6j6v.ewbj). + * + * The SDK has two lists that look alike and take different things: `tools` (the base toolset) takes + * tool NAMES, `allowedTools` (the auto-approval list) takes RULES, and a rule may scope a tool to + * some of its uses. A scoped rule put into `tools` names no tool at all, so the base set would lack + * the very tool the rule approves. + * + * @param {string} rule - a tool name or a permission rule. + * @returns {string} the tool it names. + */ +export function toolOfRule(rule) { + const open = rule.indexOf("("); + return open > 0 && rule.endsWith(")") ? rule.slice(0, open) : rule; +} + /** * Decide what main.mjs should assign to the SDK's `options.tools` (the base toolset), given * `spec.tools` and `spec.grantedTools` as parsed from the role spec JSON. * - * Mirrors the exact guard main.mjs used inline: `if (Array.isArray(spec.tools)) options.tools = - * spec.tools;`. Only an actual array — including `[]` — is a genuine declaration of intent: + * Only an actual array — including `[]` — is a genuine declaration of intent: * - `tools` absent (`undefined`) or `null` -> returns `undefined`, meaning "leave * `options.tools` unset", so the SDK's default full toolset applies. The grant is NOT folded * in here, and that is the point: the default set already contains everything the grant names, * and turning "unset" into a list would SHRINK an undeclared role's toolset to exactly the * grant — which is the regression this whole split exists to avoid. - * - `tools: []` -> the grant, and nothing else. An explicit zero-tools role that is put under an - * obligation gets exactly the means for that obligation and no more — the same narrow scoping - * nxf 6j6v.04es gave the `summarize` synthesizer by hand, now derived. - * - `tools: [...]` -> that array plus anything granted that is not already in it, order - * preserved (declaration first). + * - `tools: []` -> the TOOL the grant names, and nothing else. Since nxf 6j6v.ewbj the engine + * grants such a role the rule `Bash(nxc reply:*)`, so this is `["Bash"]`, and what may run + * inside it without approval is decided by {@link resolveAllowedTools} — the rule, not the + * whole shell. + * - `tools: [...]` -> the tools that array names plus any granted tool not already in it, order + * preserved (declaration first). A declared rule (`Bash(nxc reply:*)`) contributes its tool, so + * a narrowly declared role has a base set the rule can act in. * * @param {unknown} tools - `spec.tools` as parsed from the role spec (may be undefined, null, * an array, or any other JSON value). @@ -46,8 +64,10 @@ function grantedOf(grantedTools) { */ export function resolveToolsOption(tools, grantedTools) { if (!Array.isArray(tools)) return undefined; - const granted = grantedOf(grantedTools); - return [...tools, ...granted.filter((t) => !tools.includes(t))]; + const named = [...tools, ...grantedOf(grantedTools)].map((t) => + typeof t === "string" ? toolOfRule(t) : t, + ); + return named.filter((t, i) => named.indexOf(t) === i); } /** @@ -60,6 +80,9 @@ export function resolveToolsOption(tools, grantedTools) { * requires approval". Union of the declaration and the grant, so the obligation is runnable * whatever the declaration says, and a role that was granted nothing reads exactly as before. * + * Rules pass through as RULES (nxf 6j6v.ewbj): a granted `Bash(nxc reply:*)` approves that command + * and no other, which is what keeps a `tools: []` role from holding an auto-approved shell. + * * @param {unknown} tools - `spec.tools` as parsed from the role spec. * @param {unknown} [grantedTools] - `spec.grantedTools` as parsed from the role spec. * @returns {string[]} the value for `allowedTools`. diff --git a/agent-sidecar/test/spec-helpers.test.mjs b/agent-sidecar/test/spec-helpers.test.mjs index 06dac07..c285dfb 100644 --- a/agent-sidecar/test/spec-helpers.test.mjs +++ b/agent-sidecar/test/spec-helpers.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { resolveToolsOption, resolveAllowedTools, + toolOfRule, resolveModelOption, isSubcommandNotImplementedYet, isFlagNotImplementedYet, @@ -96,6 +97,39 @@ test("resolveToolsOption", async (t) => { await t.test("an older engine sends no grant at all", () => { assert.deepEqual(resolveToolsOption(["Read"], undefined), ["Read"]); }); + + // ---- a scoped grant (nxf 6j6v.ewbj) ---------------------------------------------------------- + + await t.test("a zero-tools role granted the reply rule gets the shell TOOL in its base set", () => { + // The base set takes tool names; the rule goes to the approval list (below). Without the tool + // in the base set the rule approves a tool the session does not have. + assert.deepEqual(resolveToolsOption([], ["Bash(nxc reply:*)"]), ["Bash"]); + }); + + await t.test("a declared rule contributes its tool, once", () => { + assert.deepEqual(resolveToolsOption(["Bash(nxc reply:*)"], undefined), ["Bash"]); + assert.deepEqual(resolveToolsOption(["Read", "Bash(nxc reply:*)"], ["Bash(nxc reply:*)"]), [ + "Read", + "Bash", + ]); + assert.deepEqual(resolveToolsOption(["Bash", "Read"], ["Bash(nxc reply:*)"]), ["Bash", "Read"]); + }); +}); + +test("toolOfRule", async (t) => { + await t.test("a plain tool name is itself", () => { + assert.equal(toolOfRule("Bash"), "Bash"); + assert.equal(toolOfRule("Read"), "Read"); + }); + + await t.test("a scoped rule names the tool before the parenthesis", () => { + assert.equal(toolOfRule("Bash(nxc reply:*)"), "Bash"); + }); + + await t.test("something that only looks scoped is left alone", () => { + assert.equal(toolOfRule("(nxc)"), "(nxc)"); + assert.equal(toolOfRule("Bash(unclosed"), "Bash(unclosed"); + }); }); test("resolveAllowedTools", async (t) => { @@ -124,6 +158,13 @@ test("resolveAllowedTools", async (t) => { assert.deepEqual(resolveAllowedTools(["Bash"], ["Bash"]), ["Bash"]); }); + await t.test("a scoped grant is approved as the RULE, never widened to the tool", () => { + // THE hole nxf 6j6v.ewbj closes: the grant used to be the bare `Bash`, and a `tools: []` role + // that owed a reply held an auto-approved shell. + assert.deepEqual(resolveAllowedTools([], ["Bash(nxc reply:*)"]), ["Bash(nxc reply:*)"]); + assert.ok(!resolveAllowedTools([], ["Bash(nxc reply:*)"]).includes("Bash")); + }); + await t.test("a malformed grant cannot inject a non-string into the list", () => { assert.deepEqual(resolveAllowedTools(["Read"], "Bash"), ["Read"]); assert.deepEqual(resolveAllowedTools(["Read"], [42, "Bash"]), ["Read", "Bash"]); diff --git a/changes/4gp2-held-answers-reach-their-caller.md b/changes/4gp2-held-answers-reach-their-caller.md new file mode 100644 index 0000000..cbfd0ba --- /dev/null +++ b/changes/4gp2-held-answers-reach-their-caller.md @@ -0,0 +1,25 @@ +--- +type: fixed +facade: changed +--- +[en] +An answer that reaches a caller while it is still finishing its turn is now delivered. On the +command line the background service was handed the delivery as a channel deadline on a session id +("no such thread"), so a held answer was never handed over; and the caller's sidecar, seeing its +sub-round already answered, reminded it and then posted a substitute reply in its name. A sub-round +whose answer is held for its commissioner now counts as still in flight (`waiting_on_sub_round` in +`nxc status --json` and on the facade). Also fixed: a persona that declares no `tools:` and is woken +with an answer it still has to pass on is granted its `nxc reply` (the wake carries the obligation it +still has), and a session spawned by a development build resolves that build's service home, not the +installed one. +[de] +Eine Antwort, die einen Aufrufer erreicht, während er seinen Zug noch beendet, wird jetzt +zugestellt. Auf der Kommandozeile bekam der Hintergrunddienst die Zustellung als Kanal-Frist auf +eine Sitzungs-ID („no such thread“), eine zurückgehaltene Antwort wurde also nie übergeben; und die +Sidecar des Aufrufers sah seine Unterrunde schon beantwortet, erinnerte ihn und postete dann eine +Ersatzantwort in seinem Namen. Eine Unterrunde, deren Antwort für ihren Auftraggeber zurückgehalten +wird, zählt jetzt weiter als unterwegs (`waiting_on_sub_round` in `nxc status --json` und in der +Fassade). Außerdem behoben: Eine Persona ohne `tools:`, die mit einer Antwort geweckt wird, die sie +noch weitergeben muss, darf ihr `nxc reply` ausführen (die Weckung trägt die Pflicht mit, die sie +noch hat), und eine Sitzung, die ein Entwicklungs-Build startet, nutzt dessen Dienst-Verzeichnis, +nicht das installierte. diff --git a/changes/70dy-pm-talks-to-pm.md b/changes/70dy-pm-talks-to-pm.md new file mode 100644 index 0000000..b7ebac7 --- /dev/null +++ b/changes/70dy-pm-talks-to-pm.md @@ -0,0 +1,41 @@ +--- +type: added +facade: breaking +--- +[en] +A persona can commission a persona of another workspace on the same machine, and the answer comes +back. `nxc send --to //` records the commission in the caller's own workspace +and hands over that one thread; the other workspace checks access, starts its persona in its own +working copy, and the answer — or a question, which always goes to whoever commissioned — wakes the +caller. Only the border thread crosses. Access is the receiver's: `addressable.external` admits a +role from trusted workspaces (`"*/pm"`, `nxsflow/manufakt-io/pm`), and without it nothing comes in. +New: `nxs name` (a workspace's stored `/` name), `nxs sync trust add --workspace +/` (trust a neighbouring workspace by name; needed on both sides), and `nxc status` +showing a border thread in both workspaces with the other side and its state (submitted, working, +input-required, completed, rejected, canceled). Two hours without a sign of life cancel it; +`nxc withdraw` takes it back on both sides. An engine older than this release cannot load a persona +file that carries `external:` — add it only once every reader of the repository is on this version. +For embedding apps: `EngineConfig::peers` and `Ctx::peers` (the host's way to reach other +workspaces; `None` keeps everything as before), `Engine::handover`, `Addressable::OnlyWithExternal`, +`Coordinator::Border`, and new `border` fields on `Refs`, `SendToReceipt`, `ReplyReceipt` and +`StatusThread` — a struct literal of `EngineConfig`, `Ctx`, `Adapter` or `Refs` without +`..Default::default()` needs the new field. +[de] +Eine Persona kann eine Persona eines anderen Arbeitsbereichs auf derselben Maschine beauftragen, +und die Antwort kommt zurück. `nxc send --to //` legt den Auftrag im +eigenen Arbeitsbereich ab und übergibt genau diesen einen Faden; der andere Arbeitsbereich prüft den +Zugang, startet seine Persona in seiner eigenen Arbeitskopie, und die Antwort — oder eine +Rückfrage, die immer an den Auftraggeber geht — weckt den Aufrufer. Nur der Grenzfaden geht hinüber. +Den Zugang bestimmt der Empfänger: `addressable.external` lässt eine Rolle aus vertrauten +Arbeitsbereichen zu (`"*/pm"`, `nxsflow/manufakt-io/pm`), ohne das Feld kommt nichts herein. Neu: +`nxs name` (der gespeicherte Name `/` eines Arbeitsbereichs), `nxs sync trust add +--workspace /` (einem benachbarten Arbeitsbereich beim Namen vertrauen; auf beiden +Seiten nötig) und `nxc status`, das einen Grenzfaden in beiden Arbeitsbereichen mit der Gegenseite +und seinem Zustand zeigt (submitted, working, input-required, completed, rejected, canceled). Zwei +Stunden ohne Lebenszeichen brechen ihn ab; `nxc withdraw` zieht ihn auf beiden Seiten zurück. Eine +Engine, die älter ist als dieses Release, kann eine Persona-Datei mit `external:` nicht laden — +setzen Sie das Feld erst, wenn alle Leser des Repositorys diesen Stand haben. Für einbettende Apps: +`EngineConfig::peers` und `Ctx::peers` (der Weg des Hosts zu anderen Arbeitsbereichen; `None` lässt +alles wie bisher), `Engine::handover`, `Addressable::OnlyWithExternal`, `Coordinator::Border` und neue +Felder `border` an `Refs`, `SendToReceipt`, `ReplyReceipt` und `StatusThread` — ein Struct-Literal +von `EngineConfig`, `Ctx`, `Adapter` oder `Refs` ohne `..Default::default()` braucht das neue Feld. diff --git a/changes/ewbj-tools-empty-is-a-gate.md b/changes/ewbj-tools-empty-is-a-gate.md new file mode 100644 index 0000000..4ce55be --- /dev/null +++ b/changes/ewbj-tools-empty-is-a-gate.md @@ -0,0 +1,26 @@ +--- +type: fixed +facade: changed +--- +[en] +Safety fix: a persona declared with `tools: []` can no longer run arbitrary shell commands when it +owes a reply. Until now the engine granted every such session the bare, auto-approved `Bash` so that +it could answer, which gave a persona declared to have no tools a full shell (`gh pr merge`, +`git push --force`, a release). It now grants `Bash(nxc reply:*)`: the reply runs, every other +command needs an approval nobody is there to give and is refused. A persona whose `tools:` is absent, +or lists `Bash`, behaves as before; one that declares `Bash(nxc reply:*)` itself now stays that +narrow. For an embedding app's own worker: `RoleSpec::granted_tools` (`grantedTools` in the sidecar +spec) can now carry a scoped permission rule rather than a tool name — put the tool it names into the +SDK's `tools` and the rule itself into `allowedTools`, as the bundled sidecar does. +[de] +Sicherheitskorrektur: Eine Persona mit `tools: []` kann keine beliebigen Shell-Befehle mehr +ausführen, wenn sie eine Antwort schuldet. Bisher gewährte die Engine jeder solchen Sitzung das +nackte, automatisch freigegebene `Bash`, damit sie antworten konnte — eine Persona ohne Werkzeuge +hatte damit eine volle Shell (`gh pr merge`, `git push --force`, ein Release). Jetzt gewährt sie +`Bash(nxc reply:*)`: Die Antwort läuft, jeder andere Befehl braucht eine Freigabe, die niemand +erteilen kann, und wird abgewiesen. Eine Persona ohne `tools:` oder mit `Bash` in der Liste verhält +sich wie bisher; eine, die selbst `Bash(nxc reply:*)` deklariert, bleibt jetzt so eng. Für den +eigenen Worker einer einbettenden App: `RoleSpec::granted_tools` (`grantedTools` in der +Sidecar-Spezifikation) kann jetzt eine eingeschränkte Berechtigungsregel statt eines Werkzeugnamens +tragen — das genannte Werkzeug gehört in `tools` des SDK, die Regel selbst in `allowedTools`, wie es +die mitgelieferte Sidecar tut. diff --git a/crates/chat/docs/guide/de/limits-and-safety.md b/crates/chat/docs/guide/de/limits-and-safety.md index ce58caf..8fa4972 100644 --- a/crates/chat/docs/guide/de/limits-and-safety.md +++ b/crates/chat/docs/guide/de/limits-and-safety.md @@ -87,6 +87,22 @@ ist eine Persona, deren `system_prompt` das sagt und deren Deklaration ihr keine Handeln gibt — `tools: []` ist ein echter, eigener Zustand und nicht dasselbe wie den Schlüssel wegzulassen. +Was `tools: []` genau verhindert und was nicht. Eine so deklarierte Persona schuldet ihre Antwort +trotzdem; wird sie beauftragt, gewährt ihr die Engine deshalb den einen Befehl, der diese Antwort +ist: `Bash(nxc reply:*)`. Sie kann `nxc reply` ausführen — die Heredoc-Form und die einzeilige Form +— und ohne Freigabe nichts anderes: kein `nxc send`, kein `git push`, kein `gh`, kein Schreiben einer +Datei, keine Kette, Pipe, Umleitung und kein `$(…)` um ihre Antwort herum. Weil niemand da ist, der +etwas freigibt, wird ein Befehl, der eine Freigabe braucht, abgewiesen. Was die Agenten-Laufzeit +selbst im Arbeitsverzeichnis der Persona als nur lesend zählt (`ls`, `cat` einer Datei dort, +`git status`), läuft weiterhin; diese Erlaubnis gehört der Laufzeit, nicht der Deklaration. Eine +Persona ohne `tools:` ist ein anderer Zustand: Sie läuft mit dem vollen Standardwerkzeugsatz der +Laufzeit und einer freigegebenen Shell, und nichts davon schränkt sie ein. Eine Persona mit +`permissions: bypassPermissions` gibt alles selbst frei, gleich, was in `tools:` steht. + +Eine Persona, die Aufrufer aus anderen Arbeitsbereichen zulässt (`addressable.external`, `nxc guide +personas`), bekommt Text, den ein anderer Arbeitsbereich geschrieben hat. Was dieser Text sie tun +lassen kann, begrenzt ihre Deklaration — genau deshalb muss `tools: []` bedeuten, was es sagt. + ## Eskalation: das deklarierte „ich brauche Hilfe oder eine Entscheidung" Ein Agent darf mit `reply` genau zwei Dinge sagen: *ich bin fertig* und *allein komme ich nicht ans diff --git a/crates/chat/docs/guide/de/personas.md b/crates/chat/docs/guide/de/personas.md index 5b620f0..6678f18 100644 --- a/crates/chat/docs/guide/de/personas.md +++ b/crates/chat/docs/guide/de/personas.md @@ -183,10 +183,13 @@ weggelassener Schlüssel und eine leere Liste sind *nicht* dasselbe. Eine Persona, die überhaupt `nxc` ausführen soll, braucht `Bash`, denn so antwortet sie — und wo die Engine eine Antwort *verlangt*, gibt sie das selbst dazu. Jede Beauftragung sagt der Persona in ihrem eigenen Systemprompt, sie solle ihren Zug mit `nxc reply --thread ` beenden; wer so verpflichtet, -muss also sicherstellen, dass es ausführbar ist. Der Trigger nimmt `Bash` zusätzlich zu dem auf, was -Sie deklariert haben — ohne den Werkzeugsatz zu verengen, den ein weggelassenes `tools:` gewährt. Für -eine Persona, die die Shell für ihre *Arbeit* braucht, deklarieren Sie `Bash` weiterhin selbst; was -Sie nicht mehr mitdenken müssen, ist, dass Antworten auch Arbeit ist. +muss also sicherstellen, dass es ausführbar ist. Was der Trigger dazugibt, hängt von Ihrer +Deklaration ab und verengt sie nie: Eine Persona ohne `tools:` bekommt `Bash`, wie bisher; eine +Persona mit einer Liste ohne `Bash` — auch `tools: []` — bekommt `Bash(nxc reply:*)`, das ihre +Antwort ausführt und keinen anderen Befehl; eine Persona, die `Bash` aufführt, bekommt nichts dazu. +Für eine Persona, die die Shell für ihre *Arbeit* braucht, deklarieren Sie `Bash` weiterhin selbst; +was Sie nicht mehr mitdenken müssen, ist, dass Antworten auch Arbeit ist. `limits-and-safety` sagt +genau, was die enge Gewährung durchlässt. ## Wer sie ansprechen darf @@ -243,6 +246,62 @@ anzusprechen, in der sie sitzen, lädt eine Einzelauflistung genau zu dem Aufruf wollen. Die Darstellung folgt dem *Leser*, dieselbe Deklaration kann eine Persona Ihnen also zeigen und einem Agenten verbergen. +### Aus einem anderen Arbeitsbereich + +Eine Persona eines ANDEREN Arbeitsbereichs auf derselben Maschine kann diese beauftragen, wenn die +Deklaration es erlaubt. Ihre Adresse von außen ist der Name des Arbeitsbereichs und das Handle der +Persona: `nxc send --to nxsflow/nexus-flow/pm`. Der Name eines Arbeitsbereichs ist +`/`; `nxs name` zeigt ihn (aus der Git-Herkunft abgeleitet und beim ersten Gebrauch +gespeichert), `nxs name /` setzt ihn, wo es keine Herkunft gibt. Innerhalb eines +Arbeitsbereichs ändert sich nichts: `pm` bleibt `pm`. + +```yaml +addressable: + humans: true + external: + - "*/pm" # der PM jedes Arbeitsbereichs, dem dieser vertraut + - nxsflow/manufakt-io/pm # oder genau dieser +``` + +- **Kein `external`, kein Weg von außen herein** — in jeder Form, auch unter `general`: „jeder“ + heißt jeder in diesem Arbeitsbereich. Ein bloßes Handle in `personas:` meint die gleichnamige + Persona dieses Arbeitsbereichs und lässt nie eine fremde herein: `personas: [pm]` lässt den `pm` + eines anderen Arbeitsbereichs nicht durch. +- **Zwei Prüfungen, beide nötig.** Der Arbeitsbereich des Aufrufers steht auf der Vertrauensliste + dieses Arbeitsbereichs, UND seine Rolle steht in `external`. Einen Nachbarn nehmen Sie beim Namen + auf mit `nxs sync trust add --workspace /` — **in beiden Arbeitsbereichen**: Die + Antwort kommt mit dem Schlüssel des Empfängers signiert zurück, und eine Antwort aus einem + Arbeitsbereich, dem der Aufrufer nicht vertraut, weckt niemanden. `send` lehnt deshalb vorab ab, + bis er vertraut ist. +- **Die Rolle stempelt der Koordinator des Aufrufers**, aus der Sitzung, die er selbst gestartet + hat — nie der Agent. Ein Mensch kommt von außen nicht herein: Er geht in jenen Arbeitsbereich und + schreibt dort. +- **Eine Ablehnung nennt ihren Grund**, in der Antwort an den Aufrufer: *workspace unknown*, *not + on this machine*, *not trusted*, *role not admitted*, *no such persona* oder *depth limit + reached*. + +Was dann geschieht, ist eine Konsultation mit einer Übergabe in der Mitte. Der Auftrag des +Aufrufers ist ein Faden in dessen eigenem Log, und nur dieser Faden geht hinüber: Der Empfänger +startet seine Persona in SEINER Arbeitskopie, mit SEINEM Board und Gedächtnis, und was diese Persona +beauftragt, um zu antworten, bleibt in ihrem Arbeitsbereich. Eine Rückfrage (`--escalate`) geht an +den Auftraggeber zurück — nie an ihm vorbei zum Besitzer des anderen Arbeitsbereichs —, und die +Antwort des Auftraggebers auf demselben Faden setzt die Persona fort. Die Tiefengrenze gilt für die +ganze Kette über beide Arbeitsbereiche. `nxc status` zeigt den Faden in beiden Arbeitsbereichen mit +der Gegenseite und einem von vier Zuständen: `submitted`, `working`, `input-required`, `completed` +(oder `rejected` / `canceled`). Zwei Stunden ohne Lebenszeichen — keine Nachricht auf dem Faden, +nichts im Transkript der empfangenden Persona — brechen ihn ab und wecken den Auftraggeber mit dem +Grund; `nxc withdraw` auf der Operation zieht ihn auch im anderen Arbeitsbereich zurück. + +`send` und `reply` tragen einen Grenzfaden sofort hinüber, die Zustellung braucht also keinen +Hintergrunddienst; den Rest trägt der Dienst in seinem Takt. Ein Fall braucht ihn doch, wie +innerhalb eines Arbeitsbereichs: Eine Antwort, die eintrifft, während ihr Auftraggeber seinen Zug +noch beendet, wird zurückgehalten und vom Dienst übergeben, sobald der Zug endet. + +**Ausrollen.** Die Mapping-Form von `addressable` lehnt Schlüssel ab, die sie nicht kennt — das fängt +einen Tippfehler wie `persona:` —, darum kann eine Engine, die älter ist als `external`, eine +Persona-Datei mit dem Feld nicht laden. Setzen Sie `external:` nur in ein Repository, dessen Leser +alle — auch Apps, die eine Engine fest einbinden — einen Stand haben, der es kennt. + ## Wen sie ansprechen darf ```yaml diff --git a/crates/chat/docs/guide/en/limits-and-safety.md b/crates/chat/docs/guide/en/limits-and-safety.md index 08309c6..9aff66c 100644 --- a/crates/chat/docs/guide/en/limits-and-safety.md +++ b/crates/chat/docs/guide/en/limits-and-safety.md @@ -82,6 +82,22 @@ The other human gate is the one you write yourself. A persona that must not act a persona whose `system_prompt` says so and whose declaration gives it no tools to act with — `tools: []` is a real, distinct state, not the same as leaving the key out. +What `tools: []` does and does not prevent, exactly. A persona declared that way still owes its +answer, so when it is commissioned the engine grants it the one command that answer is: +`Bash(nxc reply:*)`. It can run `nxc reply` — the heredoc form and the one-line form — and nothing +else without approval: not `nxc send`, not `git push`, not `gh`, not a file write, not a chain, +pipe, redirection or `$(…)` around its reply. Because no person is there to approve anything, a +command that needs approval is refused. What the agent runtime itself counts as read-only inside +the persona's working directory (`ls`, `cat` of a file there, `git status`) still runs; that +allowance is the runtime's, not the declaration's. A persona that omits `tools:` is a different +state: it runs on the runtime's full default toolset with an approved shell, and is not limited by +any of this. A persona that declares `permissions: bypassPermissions` approves everything itself, +whatever its `tools:` says. + +A persona that admits callers from other workspaces (`addressable.external`, `nxc guide personas`) +is handed text another workspace wrote. Its declaration is what bounds what that text can make it +do — which is exactly why `tools: []` has to mean what it says. + ## Escalation: the declared "I need help, or a decision" An agent has exactly two things it may say with `reply`: *I am finished*, and *I cannot reach the diff --git a/crates/chat/docs/guide/en/personas.md b/crates/chat/docs/guide/en/personas.md index d0293fc..a86832f 100644 --- a/crates/chat/docs/guide/en/personas.md +++ b/crates/chat/docs/guide/en/personas.md @@ -172,10 +172,12 @@ non-agentic role. Write a list and it gets exactly that list. An omitted key and A persona that will run `nxc` at all needs `Bash`, since that is how it answers — and where the engine *orders* an answer, it grants that itself. Every commission tells the persona, in its own system prompt, to end its turn with `nxc reply --thread `, so whoever imposes that obligation has -to make sure it can be carried out: the trigger adds `Bash` to what the session may run, on top of -whatever you declared, without narrowing the toolset an omitted `tools:` gives it. You still declare -`Bash` for a persona that needs the shell for its *work*; what you no longer have to remember is that -answering is itself work. +to make sure it can be carried out. What the trigger adds depends on what you declared, and never +narrows it: a persona that omits `tools:` gets `Bash`, as it always did; a persona that declares a +list without `Bash` — `tools: []` included — gets `Bash(nxc reply:*)`, which runs its reply and no +other command; a persona that lists `Bash` gets nothing more. You still declare `Bash` for a persona +that needs the shell for its *work*; what you no longer have to remember is that answering is itself +work. `limits-and-safety` says exactly what the narrow grant lets through. ## Who may address it @@ -229,6 +231,56 @@ it is. That is usually what you want: if the way to reach four reviewers is to a they are in, listing them individually invites exactly the call you do not want. The rendering follows the *reader*, so the same declaration can show a persona to you and hide it from an agent. +### From another workspace + +A persona of ANOTHER workspace on the same machine can commission this one, if the declaration says +so. Its address from outside is the workspace's name and the persona's handle: +`nxc send --to nxsflow/nexus-flow/pm`. A workspace's name is `/`; `nxs name` prints it +(derived from the git origin and stored on first use) and `nxs name /` sets it where +there is no origin. Inside a workspace nothing changes: `pm` stays `pm`. + +```yaml +addressable: + humans: true + external: + - "*/pm" # the PM of every workspace this one trusts + - nxsflow/manufakt-io/pm # or exactly this one +``` + +- **No `external`, no way in from outside** — under every form, `general` included: "anyone" means + anyone in this workspace. A bare handle in `personas:` means this workspace's persona of that name + and never admits a foreign one: `personas: [pm]` does not let another workspace's `pm` in. +- **Two checks, both required.** The caller's workspace is on this workspace's trust list, AND its + role is listed in `external`. Trust a neighbour by name with + `nxs sync trust add --workspace /` — **in both workspaces**: the answer comes back + signed by the receiver's key, and an answer from a workspace the caller does not trust wakes + nobody, so `send` refuses up front until it is trusted. +- **The role is stamped by the caller's coordinator**, from the session it started — never by the + agent. A person does not come in from outside: they go to that workspace and write there. +- **A refusal says why**, in the caller's answer: *workspace unknown*, *not on this machine*, *not + trusted*, *role not admitted*, *no such persona*, or *depth limit reached*. + +What happens then is a consultation with one handover in the middle. The caller's commission is a +thread in its own log, and only that thread crosses: the receiver starts its persona in ITS working +copy, with ITS board and memory, and whatever that persona commissions to answer stays in its own +workspace. A question (`--escalate`) goes back to the commissioner — never past it to the other +workspace's owner — and the commissioner's answer on the same thread resumes the persona. The depth +cap holds for the whole chain across both workspaces. `nxc status` shows the thread in both +workspaces with the other side and one of four states: `submitted`, `working`, `input-required`, +`completed` (or `rejected` / `canceled`). Two hours without a sign of life — no message on the +thread, nothing in the receiving persona's transcript — cancel it and wake the commissioner with +the reason; `nxc withdraw` on the operation takes it back in the other workspace too. + +`send` and `reply` carry a border thread across at once, so delivery does not need the background +service; the service's pass carries the rest. One case does need it, as it does inside a workspace: +an answer that arrives while its commissioner is still finishing its turn is held, and handed over +by the service when that turn ends. + +**Rolling it out.** `addressable`'s mapping refuses keys it does not know — that is what catches a +typo like `persona:` — so an engine older than `external` cannot load a persona file that carries +it. Add `external:` only to a repository whose every reader, apps that pin an engine included, runs +a version that knows it. + ## Who it may address ```yaml diff --git a/crates/chat/src/awaiting.rs b/crates/chat/src/awaiting.rs index 6134ba3..fea22cd 100644 --- a/crates/chat/src/awaiting.rs +++ b/crates/chat/src/awaiting.rs @@ -119,6 +119,57 @@ pub fn own_open_sub_round<'a>( opened_by(&thread.outstanding, children) } +/// [`own_open_sub_round`], with one more child counted as still running: one whose answer is IN +/// but HELD for its commissioner, because the commissioner was still mid-turn when it arrived +/// (nxf 6j6v.4gp2). `held` names the threads that hold such an answer. +/// +/// Without it the status row said "nothing to wait for" in exactly the window where the answer was +/// on its way: the commissioner's sidecar, ending its turn, read the row, saw a thread it still +/// owed and no open sub-round, and REMINDED it — and a session that rightly declined to answer +/// before it had the answer was then answered for by the runtime. A consultee that answers within +/// seconds (another workspace's PM reading one line of its board) is all it takes. The held answer +/// is delivered when the commissioner's turn ends; until then it is waiting, which is what this +/// says. +pub fn own_sub_round_in_flight<'a>( + thread: &ThreadQuorum, + children: impl IntoIterator + Clone, + held: &std::collections::HashSet, +) -> Vec { + if thread.outstanding.is_empty() { + return Vec::new(); + } + let mut waiting = opened_by(&thread.outstanding, children.clone()); + let answered_but_held: Vec<&ThreadQuorum> = children + .into_iter() + .filter(|child| child.outstanding.is_empty() && held.contains(&child.thread_id)) + .collect(); + for id in opened_by_regardless(&thread.outstanding, answered_but_held) { + if !waiting.contains(&id) { + waiting.push(id); + } + } + waiting.sort(); + waiting +} + +/// [`opened_by`]'s opener rule without its "still open" filter — for children already known to be +/// answered whose answer has not reached the commissioner yet. +fn opened_by_regardless<'a>( + waiting_for: &[String], + children: impl IntoIterator, +) -> Vec { + children + .into_iter() + .filter(|child| { + child.opener.as_deref().is_some_and(|opener| { + !crate::orchestration::is_engine_identity(opener) + && waiting_for.iter().any(|owed| owed == opener) + }) + }) + .map(|child| child.thread_id.clone()) + .collect() +} + /// **The same question asked of ONE party: is `handle` waiting on a round IT commissioned?** (nxf /// 6j6v.hw2t, review of PR #460 · Code Quality #2, corroborated independently as Integrity #2). /// @@ -519,6 +570,34 @@ mod tests { } } + #[test] + fn a_sub_round_whose_answer_is_held_for_its_commissioner_is_still_in_flight() { + // nxf 6j6v.4gp2: the answer came back while the commissioner was still ending its turn, so + // it is HELD for it. Until it is delivered the commissioner is waiting — not owing an answer + // it could give, which is what the status row used to say, and what made its sidecar remind + // it into a substituted reply. + let root = quorum(&["47jy/pm"]); + let kids = [ + child("m-answered-held", "47jy/pm", &[]), + child("m-answered-delivered", "47jy/pm", &[]), + child("m-somebody-elses", "47jy/coder", &[]), + ]; + let held: std::collections::HashSet = [ + "m-answered-held".to_string(), + "m-somebody-elses".to_string(), + ] + .into_iter() + .collect(); + assert_eq!(own_open_sub_round(&root, kids.iter()), Vec::::new()); + assert_eq!( + own_sub_round_in_flight(&root, kids.iter(), &held), + vec!["m-answered-held".to_string()], + "only the commissioner's own round, and only while its answer is held" + ); + // A thread that owes nothing waits for nothing, held answers or not. + assert!(own_sub_round_in_flight(&quorum(&[]), kids.iter(), &held).is_empty()); + } + #[test] fn a_window_is_reported_only_while_somebody_still_owes_an_answer_against_it() { // nxf 6j6v.0vd9's second half, at the one function that decides it. The condition is diff --git a/crates/chat/src/border.rs b/crates/chat/src/border.rs new file mode 100644 index 0000000..4788144 --- /dev/null +++ b/crates/chat/src/border.rs @@ -0,0 +1,1745 @@ +//! **A conversation across the border of a workspace** (nxf 6j6v.70dy: 6j6v.q32p, 6j6v.t5xb, +//! 6j6v.4gp2, 6j6v.szc5) — the persona of one workspace commissions a persona of another workspace +//! on the same machine, and the answer comes back. +//! +//! # The path +//! +//! It is the in-house consultation's, with one handover in the middle: +//! +//! 1. The caller runs `nxc send --to //`. Its coordinator records the +//! commission in its OWN log ([`commission`]) — a thread under the caller's current one, exactly +//! as an in-house consultation, expecting a reply from the receiver's participant +//! (`/`). The caller ends its turn and waits, as it would in-house. +//! 2. [`hand_over`] copies the ops of that ONE thread into the receiver's log. Only the border +//! thread crosses: its register ops, its messages and its direct conversation. What either side +//! commissions behind it stays where it was written. +//! 3. The receiver's coordinator — the same verb, run in the receiver's workspace — checks access +//! ([`Refusal`]) and starts its persona in ITS working copy, with ITS board and memory. +//! 4. The answer, a question or an escalation is copied back by the same verb and wakes the +//! paused caller. +//! +//! # One mechanism +//! +//! The handover is a verb, `nxc handover`, like `nxc tick`: the background service runs it on its +//! pass for a workspace with an open border thread, and `nxc send` and `nxc reply` on a border +//! thread run it at once — here, and then in the peer's own workspace through [`Peers::hand_over_in`] +//! — so delivery does not need a running service. Each run acts only in its own workspace; copying +//! ops is the one thing it does to the other log, and [`nxs_foundation::store::Store::apply`] is +//! idempotent per op id, which is also why nothing is echoed: an op the other side already holds is +//! not copied again. +//! +//! # Who is believed +//! +//! A copied op keeps its author's signature, and the receiving store verifies it like any op that +//! arrived over sync. Whether an action may follow is the trust list's answer (6j6v.pzkb), in BOTH +//! directions: the receiver must trust the caller's workspace for the commission to start anything, +//! and the caller's workspace must trust the receiver for the answer to wake anybody — which is why +//! [`commission`] refuses up front when it does not. The ROLE is stamped by the caller's +//! coordinator into the signed commission ([`BorderRef::role`]), from the session map — never by +//! the agent, and never for a caller without a session: a person does not come in from outside in +//! this slice. + +use std::path::PathBuf; + +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; + +use crate::error::{NxfError, Result}; +use crate::model::{Disposition, MessageKind, Priority, Refs}; +use crate::orchestration::{self as orch, Ctx}; +use crate::store::{ChatStore, MessageRow}; + +/// **An address from another workspace**: `//` — the workspace's name +/// (`nxs_foundation::workspace_name`), then the persona's handle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Address<'a> { + pub workspace: &'a str, + pub persona: &'a str, +} + +impl std::fmt::Display for Address<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.workspace, self.persona) + } +} + +/// Read `to` as an address from another workspace: the last segment is the persona, everything +/// before it a valid workspace name. `None` for anything else — a bare handle, a qualified +/// participant (`ab12/pm`, one slash) and a malformed name all keep meaning what they meant. +pub fn parse_address(to: &str) -> Option> { + let (workspace, persona) = to.rsplit_once('/')?; + if persona.is_empty() || !workspace.contains('/') { + return None; + } + let handle_ok = persona.len() <= 64 + && !persona.starts_with("__") + && persona + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + && persona != "." + && persona != ".."; + (handle_ok && nxs_foundation::workspace_name::is_valid_name(workspace)) + .then_some(Address { workspace, persona }) +} + +/// **What the caller's coordinator stamps into a border commission** — rides on +/// [`Refs::border`], so it is part of what the caller's key signs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BorderRef { + /// The caller's workspace, by name. + pub from: String, + /// The address the commission was sent to, `//`. + pub to: String, + /// The caller's role, bare — read by the caller's coordinator from the session it started. + /// `None` for a caller without a session; the receiver admits no such caller. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + /// The caller's depth in its own chain. The receiver starts its persona one deeper, so the cap + /// holds for the whole chain across both workspaces. + #[serde(default)] + pub hop: u32, + /// On the receiver's answer only: the reason the commission was refused. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refusal: Option, + /// On the caller's last word only: the commission is taken back — withdrawn by its owner, or + /// given up after [`DEADLINE_HOURS`] without a sign of life. The receiver stops its persona. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub canceled: bool, +} + +/// **Why a border commission was refused** — named, so a caller learns which of its two checks +/// failed, or that the address led nowhere. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Refusal { + /// The caller's workspace is not one this machine knows by that name, or the key that signed + /// the commission is not that workspace's. + WorkspaceUnknown, + /// The addressed workspace is not on this machine. + NotOnThisMachine, + /// The caller's workspace is known but not on the receiver's trust list. + NotTrusted, + /// The caller's role is not admitted by the receiver's `addressable.external`, or there is no + /// role at all (a person). + RoleNotAdmitted, + /// The receiver declares no persona of that name. + NoSuchPersona, + /// Starting the receiver's persona would pass the depth cap of the whole chain. + DepthLimit, +} + +impl Refusal { + /// The reason in words, as a refusal states it. + pub fn text(self) -> &'static str { + match self { + Refusal::WorkspaceUnknown => "workspace unknown", + Refusal::NotOnThisMachine => "not on this machine", + Refusal::NotTrusted => "not trusted", + Refusal::RoleNotAdmitted => "role not admitted", + Refusal::NoSuchPersona => "no such persona", + Refusal::DepthLimit => "depth limit reached", + } + } +} + +/// **Where a border thread stands** — the four states A2A knows, so that a later slice adds a wire +/// protocol and nothing else (6j6v.szc5). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BorderState { + /// In the caller's own log, not handed over yet. + Submitted, + /// Handed over; the receiver's persona is running. + Working, + /// A question or an escalation is waiting with the commissioner. + InputRequired, + /// The answer is there. + Completed, + /// Access was refused. + Rejected, + /// Withdrawn, or given up after two hours without a sign of life. + Canceled, +} + +impl BorderState { + pub fn as_str(self) -> &'static str { + match self { + BorderState::Submitted => "submitted", + BorderState::Working => "working", + BorderState::InputRequired => "input-required", + BorderState::Completed => "completed", + BorderState::Rejected => "rejected", + BorderState::Canceled => "canceled", + } + } + + fn parse(s: &str) -> BorderState { + match s { + "working" => BorderState::Working, + "input-required" => BorderState::InputRequired, + "completed" => BorderState::Completed, + "rejected" => BorderState::Rejected, + "canceled" => BorderState::Canceled, + _ => BorderState::Submitted, + } + } + + /// Whether nothing more happens on the thread: a refusal and a cancellation end it. A completed + /// thread stays open to the exchange — the caller may write again, and the answer to that is + /// still carried. + pub fn is_final(self) -> bool { + matches!(self, BorderState::Rejected | BorderState::Canceled) + } + + /// Whether one side still has work to do on it: not answered, not refused, not withdrawn. + pub fn is_in_progress(self) -> bool { + matches!( + self, + BorderState::Submitted | BorderState::Working | BorderState::InputRequired + ) + } +} + +/// Which side of the border this workspace is on for one thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + /// This workspace commissioned. + Outbound, + /// This workspace was commissioned. + Inbound, +} + +impl Direction { + fn as_str(self) -> &'static str { + match self { + Direction::Outbound => "out", + Direction::Inbound => "in", + } + } +} + +/// **One border thread as this workspace records it** — device-local, like the session map: it +/// names where the other half of the conversation lives on THIS machine. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BorderRow { + pub thread_id: String, + pub direction: Direction, + /// The other workspace, by name. + pub peer: String, + /// Outbound: the persona addressed there. Inbound: the persona here that was addressed. + pub persona: String, + pub state: BorderState, + /// Why it was rejected or canceled. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Outbound: the caller's session. Inbound: the persona session started for it. + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + pub updated: String, +} + +/// **Another workspace on this machine** — what [`Peers::find`] answers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerWorkspace { + pub name: String, + /// The repository root — the directory that holds `.nxs/`. + pub root: PathBuf, +} + +/// **How this host reaches other workspaces on the machine** — the composition root's capability, +/// like [`crate::machine::Machines`]: the `nxs` binary answers it from the service registry +/// (`~/.nexusflow/workspaces.toml`) and runs the peer's handover as a child process in the peer's +/// root. `None` on a [`Ctx`] — the default for an embedding host that supplies none — reaches no +/// other workspace: an address from outside is refused, and nothing else changes. +pub trait Peers: Send + Sync + std::fmt::Debug { + /// The name of the workspace whose db is `db_path` — stored, or derived and stored. + fn here(&self, db_path: &str) -> std::result::Result, String>; + /// Every workspace on this machine that carries `name`. More than one is a refusal that names + /// all of them. + fn find(&self, name: &str) -> std::result::Result, String>; + /// Run the handover in `peer`'s own workspace now, and return when it has run. Best effort: the + /// service runs it on its pass anyway. + fn hand_over_in(&self, peer: &PeerWorkspace) -> std::result::Result<(), String>; +} + +/// **The file the background service looks for** in a workspace's `.nxs/` (nxf 6j6v.4gp2): present +/// exactly while the workspace has a border thread that is not finished, so the service's pass costs +/// one `stat` per workspace instead of opening its database every second. +pub const OPEN_MARKER: &str = "border-open"; + +/// Keep [`OPEN_MARKER`] in step with the record. `db_path` is the workspace's database, whose +/// directory is `.nxs/`. Best effort: a marker that could not be written costs the service's pass, +/// never a write. +fn refresh_marker(store: &ChatStore, db_path: &str) { + let Some(dir) = std::path::Path::new(db_path).parent() else { + return; + }; + let marker = dir.join(OPEN_MARKER); + // In progress only: a completed thread stays open to a further exchange, but that one starts + // with a write, and the write carries itself across — the service has nothing to poll for it. + let open = rows(store) + .map(|rows| rows.iter().any(|r| r.state.is_in_progress())) + .unwrap_or(false); + let _ = if open { + std::fs::write(&marker, b"") + } else { + std::fs::remove_file(&marker).or(Ok(())) + }; +} + +/// Hours without a sign of life after which a working border thread is given up — the paused +/// consultation's deadline (6j6v.nj20, owner 2026-09-17). +pub const DEADLINE_HOURS: i64 = 2; + +// ---- the record ------------------------------------------------------------------------------ + +/// The device-local tables this module keeps. Applied with the chat views. +pub(crate) const BORDER_DDL: &str = " + CREATE TABLE IF NOT EXISTS border_threads( + thread_id TEXT PRIMARY KEY, + direction TEXT NOT NULL, + peer TEXT NOT NULL, + persona TEXT NOT NULL, + state TEXT NOT NULL, + reason TEXT, + session TEXT, + created TEXT NOT NULL, + updated TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS border_served( + thread_id TEXT NOT NULL, + message_id TEXT NOT NULL, + PRIMARY KEY(thread_id, message_id) + ); +"; + +fn row_of(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let direction: String = r.get(1)?; + let state: String = r.get(4)?; + Ok(BorderRow { + thread_id: r.get(0)?, + direction: if direction == "in" { + Direction::Inbound + } else { + Direction::Outbound + }, + peer: r.get(2)?, + persona: r.get(3)?, + state: BorderState::parse(&state), + reason: r.get(5)?, + session: r.get(6)?, + updated: r.get(7)?, + }) +} + +const ROW_COLUMNS: &str = "thread_id, direction, peer, persona, state, reason, session, updated"; + +/// This workspace's record of `thread`, if it is a border thread. +pub fn row(store: &ChatStore, thread: &str) -> Result> { + Ok(store + .connection() + .query_row( + &format!("SELECT {ROW_COLUMNS} FROM border_threads WHERE thread_id = ?1"), + [thread], + row_of, + ) + .optional()?) +} + +/// Every border thread this workspace records, oldest first. +pub fn rows(store: &ChatStore) -> Result> { + let conn = store.connection(); + let mut st = conn.prepare(&format!( + "SELECT {ROW_COLUMNS} FROM border_threads ORDER BY created, thread_id" + ))?; + let rows = st + .query_map([], row_of)? + .collect::>>()?; + Ok(rows) +} + +/// Whether `thread` is a border thread here. +pub fn is_border(store: &ChatStore, thread: &str) -> Result { + Ok(row(store, thread)?.is_some()) +} + +/// Whether `thread` is a border thread this workspace COMMISSIONED. Such a thread expects a reply +/// from the receiver's participant (`/`), and a reader that reduces a handle to +/// its last segment would take it for a chat with this workspace's own persona of that name. +pub(crate) fn is_outbound(store: &ChatStore, thread: &str) -> bool { + matches!(row(store, thread), Ok(Some(r)) if r.direction == Direction::Outbound) +} + +fn insert_row( + store: &ChatStore, + thread: &str, + direction: Direction, + peer: &str, + persona: &str, + session: Option<&str>, + now: &str, +) -> Result<()> { + store.connection().execute( + "INSERT OR IGNORE INTO border_threads + (thread_id, direction, peer, persona, state, reason, session, created, updated) + VALUES (?1, ?2, ?3, ?4, 'submitted', NULL, ?5, ?6, ?6)", + params![thread, direction.as_str(), peer, persona, session, now], + )?; + Ok(()) +} + +fn set_state( + store: &ChatStore, + thread: &str, + state: BorderState, + reason: Option<&str>, + now: &str, +) -> Result<()> { + store.connection().execute( + "UPDATE border_threads SET state = ?2, reason = COALESCE(?3, reason), updated = ?4 + WHERE thread_id = ?1", + params![thread, state.as_str(), reason, now], + )?; + Ok(()) +} + +fn set_session(store: &ChatStore, thread: &str, session: &str) -> Result<()> { + store.connection().execute( + "UPDATE border_threads SET session = ?2 WHERE thread_id = ?1", + params![thread, session], + )?; + Ok(()) +} + +/// Mark `message` as handed to this side's party. `true` when it was not marked before — the one +/// question that makes a handover run twice hand nothing over twice. +fn mark_served(store: &ChatStore, thread: &str, message: &str) -> Result { + let n = store.connection().execute( + "INSERT OR IGNORE INTO border_served(thread_id, message_id) VALUES (?1, ?2)", + params![thread, message], + )?; + Ok(n > 0) +} + +/// Give `message` back after a delivery that put nothing in motion, so the next handover tries again. +fn unmark_served(store: &ChatStore, thread: &str, message: &str) { + let _ = store.connection().execute( + "DELETE FROM border_served WHERE thread_id = ?1 AND message_id = ?2", + params![thread, message], + ); +} + +/// **Claim the admission of an inbound commission** — `true` for exactly one of however many +/// handovers look at it at once (the service's pass, a `send`, the peer's child process). The claim +/// is the state change itself, compare-and-swap in one statement, so two runs cannot both start a +/// persona for one commission. +fn claim_admission(store: &ChatStore, thread: &str, now: &str) -> Result { + let n = store.connection().execute( + "UPDATE border_threads SET state = 'working', updated = ?2 + WHERE thread_id = ?1 AND state = 'submitted'", + params![thread, now], + )?; + Ok(n == 1) +} + +fn is_served(store: &ChatStore, thread: &str, message: &str) -> Result { + Ok(store + .connection() + .query_row( + "SELECT 1 FROM border_served WHERE thread_id = ?1 AND message_id = ?2", + params![thread, message], + |_| Ok(()), + ) + .optional()? + .is_some()) +} + +fn border_of(m: &MessageRow) -> Option { + let refs: Refs = serde_json::from_str(m.refs.as_deref()?).ok()?; + refs.border +} + +// ---- the other workspace --------------------------------------------------------------------- + +/// The other workspace, opened: its log, and who it is. +struct Far { + /// Its replica prefix — the origin its participants carry. + prefix: String, + store: ChatStore, +} + +fn open_far(peer: &PeerWorkspace) -> Result { + let ws = nxs_foundation::workspace::discover(&peer.root).map_err(|e| { + NxfError::io(format!( + "the workspace {} at {} cannot be opened: {}", + peer.name, + peer.root.display(), + e.msg + )) + })?; + let store = ChatStore::open(&ws.db_path().to_string_lossy(), ws.replica.site_id)?; + Ok(Far { + prefix: ws.replica.prefix.clone(), + store, + }) +} + +/// The one workspace on this machine named `name`, or the refusal that says why there is none. +fn find_one(peers: &dyn Peers, name: &str) -> Result> { + let found = peers.find(name).map_err(NxfError::io)?; + match found.as_slice() { + [] => Ok(Err(Refusal::NotOnThisMachine)), + [one] => Ok(Ok(one.clone())), + many => Err(NxfError::validation(format!( + "{} workspaces on this machine are named {name}, so the address is ambiguous: {} — \ + rename one with `nxs name /` in its directory", + many.len(), + many.iter() + .map(|p| p.root.display().to_string()) + .collect::>() + .join(", ") + ))), + } +} + +/// The ids of every op that makes up `thread` in `store`: its register ops, its messages, and the +/// direct conversation it lives in. Nothing else — this is the whole of what crosses the border. +/// +/// **The channel is checked, not taken on trust.** A border thread lives in the direct conversation +/// between the two parties it was opened for — its opener and the one participant it expects — and +/// that conversation's id is derived from exactly those two handles. A thread whose log names any +/// other channel (a local channel, `decl:planning`) is refused whole: copying its channel and +/// membership ops would let one workspace write into a conversation of the other's. Messages are +/// taken only from that direct conversation, for the same reason. +fn thread_op_ids(store: &ChatStore, thread: &str) -> Result> { + let channel = store + .thread_channel(thread) + .or_else(|| store.thread_channel_via_message(thread)) + .unwrap_or_default(); + // The two members of a direct conversation are its whole identity: its id is derived from + // them. Read from the membership rather than from the thread's expectation, which a withdrawal + // clears and which must not decide whether the withdrawal itself can cross. + let opener = store.thread_opener(thread)?.unwrap_or_default(); + let members = store.resolve_membership(&channel)?; + let direct = match members.as_slice() { + [a, b] => channel == orch::dm_channel_id(a, b) && (opener == *a || opener == *b), + _ => false, + }; + if !direct { + return Err(NxfError::validation(format!( + "border thread {thread} does not live in the direct conversation between its two \ + parties ({channel}), so nothing of it is copied" + ))); + } + let conn = store.connection(); + let mut st = conn.prepare( + "SELECT op_id FROM ops + WHERE (target_kind = 'thread' AND target_id = ?1) + OR (target_kind = 'message' + AND target_id IN (SELECT message_id FROM messages + WHERE thread_id = ?1 AND channel_id = ?2)) + OR (target_kind = 'channel' AND target_id = ?2) + OR (target_kind = 'membership' AND substr(target_id, 1, length(?2) + 1) = ?2 || char(31)) + ORDER BY lamport, site", + )?; + let ids = st + .query_map(params![thread, channel], |r| r.get(0))? + .collect::>>()?; + Ok(ids) +} + +/// Copy the ops of `thread` that `to` does not hold yet from `from`. Returns how many were copied. +fn copy_thread(from: &ChatStore, to: &mut ChatStore, thread: &str) -> Result { + let ids = thread_op_ids(from, thread)?; + let missing: Vec = ids.into_iter().filter(|id| !to.holds_op(id)).collect(); + if missing.is_empty() { + return Ok(0); + } + let ops = from.ops_with_ids(&missing); + let n = ops.len(); + to.apply(&ops); + Ok(n) +} + +// ---- commissioning --------------------------------------------------------------------------- + +/// What [`commission`] did — the border half of a send receipt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BorderReceipt { + /// The workspace the commission went to. + pub peer: String, + pub state: BorderState, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// What a border commission recorded: the thread, the message, the participant it expects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Commissioned { + pub thread_id: String, + pub message_id: String, + pub channel: String, + pub expects: String, + pub border: BorderReceipt, +} + +/// **Record a commission to a persona of another workspace, and hand it over** (6j6v.4gp2 step 1). +/// +/// Everything the receiver will check is decided by the CALLER's coordinator before anything is +/// written, and each refusal says what to do: there is no session (a person does not come in from +/// outside in this slice), the workspace is not on this machine or is ambiguous, or it is not +/// trusted HERE — its answer would arrive unvouched and could wake nobody. +pub fn commission( + ctx: &Ctx, + store: &mut ChatStore, + to: Address<'_>, + body: &str, + refs: Refs, +) -> Result { + let Some(peers) = ctx.peers else { + return Err(NxfError::validation(format!( + "{to} is an address in another workspace, and this host reaches no other workspace" + ))); + }; + let Some(role) = ctx + .session + .and_then(|s| store.session_role(s).ok().flatten()) + else { + return Err(NxfError::validation(format!( + "{to} is a persona of another workspace, and only a persona can commission it — a \ + person goes to that workspace and writes there" + ))); + }; + let here = peers + .here(ctx.db_path) + .map_err(NxfError::io)? + .ok_or_else(|| { + NxfError::validation( + "this workspace has no name, so the other side could not tell where the \ + commission comes from — set it with `nxs name /`", + ) + })?; + let peer = match find_one(peers, to.workspace)? { + Ok(p) => p, + Err(refusal) => { + return Err(NxfError::not_found(format!( + "{to}: {} — no workspace named {} is registered with the background service here", + refusal.text(), + to.workspace + ))) + } + }; + let far = open_far(&peer)?; + if far.prefix == ctx.origin { + return Err(NxfError::validation(format!( + "{} carries the same replica prefix as this workspace ({}), so its participants and \ + ours could not be told apart", + peer.name, ctx.origin + ))); + } + let far_key = far.store.key_id().to_string(); + if !store.is_trusted(&far_key) { + return Err(NxfError::validation(format!( + "{to}: {} here — its answer would arrive signed by a key this workspace does not \ + trust, and wake nobody. Trust it first: `nxs sync trust add --workspace {}`", + Refusal::NotTrusted.text(), + to.workspace + ))); + } + let hop = orch::resolve_hop(ctx, store)?; + orch::check_depth_guard(hop.get() + 1)?; + + let caller = ctx.caller_handle(); + let target = format!("{}/{}", far.prefix, to.persona); + let channel = orch::ensure_dm_channel(store, ctx.origin, ctx.actor, &caller, &target)?; + let parent = orch::ambient_parent(ctx, store); + let thread = orch::open_child_thread(ctx, store, &channel, parent.as_deref()); + let mut refs = orch::with_return_address(ctx, refs); + refs.border = Some(BorderRef { + from: here.clone(), + to: to.to_string(), + role: Some(role), + hop: hop.get(), + refusal: None, + canceled: false, + }); + let sent = crate::facade::send( + store, + crate::facade::SendRequest { + now: ctx.now, + origin: ctx.origin, + actor: ctx.actor, + channel: &channel, + body, + kind: MessageKind::Info, + priority: Priority::Normal, + disposition: Disposition::InTurn, + thread: Some(&thread), + refs, + }, + )?; + let expects = serde_json::to_string(&[&target]).expect("expects serialize"); + store.set_expects_reply_from(&thread, &expects, &caller); + insert_row( + store, + &thread, + Direction::Outbound, + &peer.name, + to.persona, + ctx.session, + ctx.now, + )?; + drop(far); + refresh_marker(store, ctx.db_path); + + after_local_write(ctx, store, &thread); + let row = row(store, &thread)?.expect("the row was just written"); + Ok(Commissioned { + thread_id: thread, + message_id: sent.message_id, + channel, + expects: target, + border: BorderReceipt { + peer: row.peer, + state: row.state, + reason: row.reason, + }, + }) +} + +/// **Something was written on a border thread here: carry it across now** — what `nxc send` and +/// `nxc reply` run, so delivery does not wait for the service. Copies this thread over, runs the +/// peer's own handover when something new reached it, and takes in what came back. Never fails +/// the write it follows: what it could not do, the service's pass does. +pub fn after_local_write(ctx: &Ctx, store: &mut ChatStore, thread: &str) { + let Some(peers) = ctx.peers else { return }; + let Ok(Some(row)) = row(store, thread) else { + return; + }; + let peer = match find_one(peers, &row.peer) { + Ok(Ok(p)) => p, + Ok(Err(_)) | Err(_) => return, + }; + let (pushed, peer_lacks_it) = match open_far(&peer) { + Ok(mut far) => ( + copy_thread(store, &mut far.store, thread).unwrap_or(0), + !holds_record(&far.store, thread), + ), + Err(e) => { + eprintln!( + "warning: border thread {thread} could not be handed over: {}", + e.msg + ); + return; + } + }; + if pushed > 0 || peer_lacks_it { + if let Err(e) = peers.hand_over_in(&peer) { + eprintln!( + "warning: the handover in {} did not run ({e}); the background service hands \ + thread {thread} over on its next pass", + peer.name + ); + } + } + if let Err(e) = hand_over(ctx, store) { + eprintln!( + "warning: taking in what came back on thread {thread} failed: {}", + e.msg + ); + } +} + +// ---- the handover ---------------------------------------------------------------------------- + +/// What one handover did — `nxc handover --json`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct HandoverReport { + /// Border threads exchanged with their peer. + pub exchanged: Vec, + /// Inbound commissions admitted: a persona was started for them. + pub admitted: Vec, + /// Inbound commissions refused, with the reason. + pub refused: Vec<(String, Refusal)>, + /// Sessions handed a message from the other side. + pub woke: Vec, + /// What could not be done, per thread. + pub failed: Vec<(String, String)>, +} + +/// **The handover verb** (6j6v.4gp2) — for every border thread of this workspace: copy its ops both +/// ways, then act on what arrived, here and only here. Also finds commissions the other side copied +/// in that this workspace has no record of yet. Idempotent: a second run copies and wakes nothing. +pub fn hand_over(ctx: &Ctx, store: &mut ChatStore) -> Result { + let mut report = HandoverReport::default(); + if let Some(peers) = ctx.peers { + discover_inbound(ctx, store, peers)?; + for row in rows(store)? { + if row.state.is_final() { + continue; + } + match exchange(store, peers, &row) { + Ok(true) => report.exchanged.push(row.thread_id.clone()), + Ok(false) => {} + Err(e) => report.failed.push((row.thread_id.clone(), e.msg)), + } + } + } + let open: Vec = rows(store)? + .into_iter() + .filter(|r| !r.state.is_final()) + .collect(); + for row in open { + let acted = match row.direction { + Direction::Inbound => act_inbound(ctx, store, &row, &mut report), + Direction::Outbound => act_outbound(ctx, store, &row, &mut report), + }; + if let Err(e) = acted { + report.failed.push((row.thread_id.clone(), e.msg)); + } + } + refresh_marker(store, ctx.db_path); + Ok(report) +} + +/// Copy one border thread both ways. `true` when anything moved. Runs the peer's handover when +/// something new reached it, so the other side acts on it without waiting for the service. +fn exchange(store: &mut ChatStore, peers: &dyn Peers, row: &BorderRow) -> Result { + let peer = match find_one(peers, &row.peer)? { + Ok(p) => p, + Err(_) => return Ok(false), + }; + let mut far = open_far(&peer)?; + let pushed = copy_thread(store, &mut far.store, &row.thread_id)?; + let pulled = copy_thread(&far.store, store, &row.thread_id)?; + // **Kicked again while the other side has not taken it in** (review of PR #14): a first kick + // that failed — the peer's database busy, its binary gone — would otherwise strand the + // commission, because nothing new is pushed the next time and the peer has no marker to make + // its own service look. + let peer_lacks_it = !holds_record(&far.store, &row.thread_id); + drop(far); + if pushed > 0 || peer_lacks_it { + if let Err(e) = peers.hand_over_in(&peer) { + eprintln!( + "warning: the handover in {} did not run ({e}); its service picks thread {} up", + peer.name, row.thread_id + ); + } + } + Ok(pushed + pulled > 0) +} + +/// Record every commission another workspace copied in that is addressed to this workspace and has +/// no record here yet. +fn discover_inbound(ctx: &Ctx, store: &mut ChatStore, peers: &dyn Peers) -> Result<()> { + let Some(here) = peers.here(ctx.db_path).map_err(NxfError::io)? else { + return Ok(()); + }; + let candidates: Vec<(String, String)> = { + let conn = store.connection(); + let mut st = conn.prepare( + "SELECT m.thread_id, m.refs FROM messages m + WHERE m.thread_id IS NOT NULL + AND json_extract(m.refs, '$.border.to') IS NOT NULL + AND json_extract(m.refs, '$.border.refusal') IS NULL + AND m.thread_id NOT IN (SELECT thread_id FROM border_threads)", + )?; + let rows = st + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>>()?; + rows + }; + for (thread, refs) in candidates { + if !is_foreign_opening(ctx, store, &thread)? { + continue; + } + let Ok(refs) = serde_json::from_str::(&refs) else { + continue; + }; + let Some(b) = refs.border else { continue }; + let Some(to) = parse_address(&b.to) else { + continue; + }; + if to.workspace != here { + continue; + } + insert_row( + store, + &thread, + Direction::Inbound, + &b.from, + to.persona, + None, + ctx.now, + )?; + } + Ok(()) +} + +/// **Whether `thread` was opened in another workspace, by a signed first message** — what a +/// commission has to be before this workspace records it (review of PR #14). The stamped message +/// must be the thread's first, written by the thread's opener, the opener must not be one of this +/// workspace's own participants (a stamp forged into a local thread would otherwise make it a +/// border thread here, and cut its parent edge), and the op behind it must carry a signature that +/// verified. Trust is not asked yet: a known neighbour that is not trusted is answered "not +/// trusted"; what this keeps out is a message nobody can be held to. +fn is_foreign_opening(ctx: &Ctx, store: &ChatStore, thread: &str) -> Result { + let Some(first) = store.messages_in_thread(thread)?.into_iter().next() else { + return Ok(false); + }; + if border_of(&first).is_none() || border_of(&first).is_some_and(|b| b.refusal.is_some()) { + return Ok(false); + } + let opener = store.thread_opener(thread)?; + if opener.as_deref() != Some(first.sender.as_str()) + || first.sender.starts_with(&format!("{}/", ctx.origin)) + { + return Ok(false); + } + Ok(store + .message_provenance(&first.message_id)? + .is_some_and(|p| p.provenance == nxs_foundation::signing::Provenance::Verified)) +} + +/// The commission that opened `thread` — its first message, with the border stamp on it. +fn opening(store: &ChatStore, thread: &str) -> Result> { + let first = store.messages_in_thread(thread)?.into_iter().next(); + Ok(first.and_then(|m| border_of(&m).map(|b| (m, b)))) +} + +/// **The receiver's coordinator, on an inbound border thread**: admit or refuse the commission once, +/// then hand the persona whatever the other side writes after it. +fn act_inbound( + ctx: &Ctx, + store: &mut ChatStore, + row: &BorderRow, + report: &mut HandoverReport, +) -> Result<()> { + let Some((first, stamp)) = opening(store, &row.thread_id)? else { + return Ok(()); + }; + let participant = ctx.qualify(&row.persona); + if row.state == BorderState::Submitted { + match admission(ctx, store, &first, &stamp, &row.persona)? { + Ok(()) => { + if !claim_admission(store, &row.thread_id, ctx.now)? { + return Ok(()); // another handover is admitting it right now + } + let session = match start_persona(ctx, store, row, &first, &stamp) { + Ok(session) => session, + Err(e) => { + // Given back, so the next handover tries again. + set_state(store, &row.thread_id, BorderState::Submitted, None, ctx.now)?; + return Err(e); + } + }; + set_session(store, &row.thread_id, &session)?; + mark_served(store, &row.thread_id, &first.message_id)?; + report.admitted.push(row.thread_id.clone()); + } + // A workspace nobody on this machine is, under the key that signed: recorded and left + // unanswered. Answering it would have this workspace sign and store a reply for every + // message anyone writes in, which is a log anyone could grow. + Err(Refusal::WorkspaceUnknown) => { + set_state( + store, + &row.thread_id, + BorderState::Rejected, + Some(Refusal::WorkspaceUnknown.text()), + ctx.now, + )?; + report + .refused + .push((row.thread_id.clone(), Refusal::WorkspaceUnknown)); + } + Err(refusal) => { + if !claim_admission(store, &row.thread_id, ctx.now)? { + return Ok(()); + } + refuse(ctx, store, row, &stamp, refusal)?; + report.refused.push((row.thread_id.clone(), refusal)); + } + } + return Ok(()); + } + // Admitted earlier: what the caller wrote since goes to the persona's session. + let Some(session) = row.session.clone() else { + return Ok(()); + }; + let acting = store.acting_messages_in_thread(&row.thread_id)?; + let fresh: Vec = acting + .iter() + .filter(|m| m.sender != participant) + .filter(|m| !is_served(store, &row.thread_id, &m.message_id).unwrap_or(true)) + .cloned() + .collect(); + // The caller took the commission back: stop the persona working on it, and hand it nothing. + if let Some(cancel) = fresh + .iter() + .find(|m| border_of(m).is_some_and(|b| b.canceled)) + { + if let Err(e) = ctx.worker.stop_session(&session) { + eprintln!( + "warning: border thread {} was withdrawn, and its persona session {session} could \ + not be stopped: {e}", + row.thread_id + ); + } + for m in &fresh { + mark_served(store, &row.thread_id, &m.message_id)?; + } + set_state( + store, + &row.thread_id, + BorderState::Canceled, + Some(&cancel.body), + ctx.now, + )?; + return Ok(()); + } + if !fresh.is_empty() { + let hop = orch::checked_hop(stamp.hop.saturating_add(1))?; + if deliver( + ctx, + store, + &session, + &row.thread_id, + &fresh, + &participant, + hop, + ) { + report.woke.push(session); + } + } + let state = state_after(acting.last(), &participant, row.state); + if state != row.state { + set_state(store, &row.thread_id, state, None, ctx.now)?; + } + Ok(()) +} + +/// Where a border thread stands, read off its newest acting message (`last`): the side that does +/// the work (`worker`, the addressed persona's participant) answered — `completed`, or +/// `input-required` when it handed the task back — or the other side wrote since, and the work is +/// on again. `current` when the thread has no message to read. +fn state_after(last: Option<&MessageRow>, worker: &str, current: BorderState) -> BorderState { + match last { + None => current, + Some(m) if m.sender == worker && m.kind == crate::model::KIND_ESCALATION => { + BorderState::InputRequired + } + Some(m) if m.sender == worker => BorderState::Completed, + Some(_) => BorderState::Working, + } +} + +/// **The two checks, both required** (6j6v.t5xb), plus whether the address leads anywhere: the +/// caller's workspace is known here under the key that signed, that key is trusted, the persona +/// exists, its role is admitted by `addressable.external`, and the chain has room for one more. +fn admission( + ctx: &Ctx, + store: &ChatStore, + first: &MessageRow, + stamp: &BorderRef, + persona: &str, +) -> Result> { + let signer = store + .message_provenance(&first.message_id)? + .and_then(|p| p.key_id); + // Fail closed: without a way to look the caller's workspace up, it is unknown. + let Some(peers) = ctx.peers else { + return Ok(Err(Refusal::WorkspaceUnknown)); + }; + match find_one(peers, &stamp.from) { + Ok(Ok(peer)) => { + let known_key = open_far(&peer).ok().map(|f| f.store.key_id().to_string()); + if known_key.is_none() || known_key != signer { + return Ok(Err(Refusal::WorkspaceUnknown)); + } + } + Ok(Err(_)) | Err(_) => return Ok(Err(Refusal::WorkspaceUnknown)), + } + if !first.acts { + return Ok(Err(Refusal::NotTrusted)); + } + let Ok(decl) = ctx.defs.role(persona) else { + return Ok(Err(Refusal::NoSuchPersona)); + }; + let Some(role) = stamp.role.as_deref() else { + return Ok(Err(Refusal::RoleNotAdmitted)); + }; + if !decl.addressable.admits_external(&stamp.from, role) { + return Ok(Err(Refusal::RoleNotAdmitted)); + } + if orch::check_depth_guard(stamp.hop.saturating_add(1)).is_err() { + return Ok(Err(Refusal::DepthLimit)); + } + Ok(Ok(())) +} + +/// Start the receiver's persona on the border thread: a fresh session, in this workspace's working +/// copy, owing the thread its reply, one hop deeper than the caller. +fn start_persona( + ctx: &Ctx, + store: &mut ChatStore, + row: &BorderRow, + first: &MessageRow, + stamp: &BorderRef, +) -> Result { + let decl = ctx.defs.role(&row.persona)?.clone(); + let participant = ctx.qualify(&row.persona); + let caller = stamp + .role + .as_deref() + .map(|r| format!("{}/{r}", stamp.from)) + .unwrap_or_else(|| stamp.from.clone()); + let body = format!( + "From {caller}, a persona of another workspace on this machine. Only this thread is \ + shared with it; what you commission to answer it stays in this workspace.\n\n{}", + first.body + ); + let guidance = orch::reply_guidance(store, ctx.now, &participant, Some(&row.thread_id)); + let wake = orch::wake_message(&body, false, &guidance); + let internal = store.mint_session_id(); + store.create_pending_session(&internal, &row.persona)?; + let hop = orch::checked_hop(stamp.hop)?; + orch::trigger_role( + ctx, + store, + orch::RoleSpawn { + decl: &decl, + internal_session: internal.clone(), + resume_real: None, + message: &wake, + coordinator: crate::worker::Coordinator::Border, + model_override: None, + hop, + chain: orch::ChainMove::Deeper, + reply_thread: Some(&row.thread_id), + thread: Some(&row.thread_id), + priority: Priority::Normal, + queued_since: None, + }, + ) + .map_err(|e| NxfError::io(format!("the persona {} did not start: {e}", row.persona)))?; + Ok(internal) +} + +/// Answer a refused commission on its own thread, in the addressed persona's name and marked as +/// the coordinator's refusal, so the caller is woken with the reason — and start nothing. +fn refuse( + ctx: &Ctx, + store: &mut ChatStore, + row: &BorderRow, + stamp: &BorderRef, + refusal: Refusal, +) -> Result<()> { + let body = format!( + "Refused by {}: {}. {}", + stamp.to, + refusal.text(), + match refusal { + Refusal::WorkspaceUnknown => format!( + "No workspace on this machine is named {} under the key that signed the commission.", + stamp.from + ), + Refusal::NotOnThisMachine => format!("{} is not on this machine.", stamp.from), + Refusal::NotTrusted => format!( + "{} is not on this workspace's trust list; its owner adds it with `nxs sync trust \ + add --workspace {}`.", + stamp.from, stamp.from + ), + Refusal::RoleNotAdmitted => format!( + "`{}` of {} is not listed in the `addressable.external` of `{}`.", + stamp.role.as_deref().unwrap_or("a person"), + stamp.from, + row.persona + ), + Refusal::NoSuchPersona => format!("This workspace declares no persona `{}`.", row.persona), + Refusal::DepthLimit => "The chain of commissions is as deep as it may go.".to_string(), + } + ); + let refs = Refs { + border: Some(BorderRef { + from: stamp + .to + .rsplit_once('/') + .map(|(w, _)| w.to_string()) + .unwrap_or_default(), + to: match stamp.role.as_deref() { + Some(role) => format!("{}/{role}", stamp.from), + None => stamp.from.clone(), + }, + role: None, + hop: stamp.hop, + refusal: Some(refusal), + canceled: false, + }), + ..Refs::default() + }; + crate::facade::reply( + store, + crate::facade::ReplyRequest { + now: ctx.now, + origin: ctx.origin, + actor: &row.persona, + target: &row.thread_id, + body: &body, + kind: MessageKind::Escalation, + priority: Priority::Normal, + disposition: Disposition::InTurn, + refs, + if_unanswered: false, + }, + )?; + set_state( + store, + &row.thread_id, + BorderState::Rejected, + Some(refusal.text()), + ctx.now, + )?; + after_local_write_quiet(ctx, store, &row.thread_id); + Ok(()) +} + +/// [`after_local_write`]'s copy-and-kick half alone — for a write the handover itself made, where +/// running the whole handover again from inside it would only recurse. +fn after_local_write_quiet(ctx: &Ctx, store: &mut ChatStore, thread: &str) { + let Some(peers) = ctx.peers else { return }; + let Ok(Some(row)) = row(store, thread) else { + return; + }; + let Ok(Ok(peer)) = find_one(peers, &row.peer) else { + return; + }; + let pushed = open_far(&peer) + .and_then(|mut far| copy_thread(store, &mut far.store, thread)) + .unwrap_or(0); + if pushed > 0 { + let _ = peers.hand_over_in(&peer); + } +} + +/// **The caller's coordinator, on an outbound border thread**: hand the paused caller whatever the +/// receiver wrote, and follow the thread's state. +fn act_outbound( + ctx: &Ctx, + store: &mut ChatStore, + row: &BorderRow, + report: &mut HandoverReport, +) -> Result<()> { + // The receiver's participant — the one party this thread was opened to expect a reply from. Its + // messages are what the caller is woken with; the caller's own are what it already knows. + let Some(worker) = store + .thread_quorum(&row.thread_id, ctx.now)? + .and_then(|q| q.expects.first().cloned()) + else { + return Ok(()); + }; + let acting = store.acting_messages_in_thread(&row.thread_id)?; + if matches!(row.state, BorderState::Working | BorderState::Submitted) + && overdue(ctx, row, &acting) + { + let why = format!( + "{}/{} gave no sign of life for {DEADLINE_HOURS} hours", + row.peer, row.persona + ); + cancel(ctx, store, row, &why)?; + return wake_with_cancellation(ctx, store, row, &why); + } + let refusal = acting + .iter() + .rev() + .filter(|m| m.sender == worker) + .find_map(|m| border_of(m).and_then(|b| b.refusal)); + let state = match refusal { + Some(_) => BorderState::Rejected, + None if acting.iter().any(|m| m.sender == worker) => { + state_after(acting.last(), &worker, row.state) + } + // Nothing back yet: working once the other side holds the thread, submitted until then. + None if row.state == BorderState::Submitted && peer_holds(ctx, row) => BorderState::Working, + None => row.state, + }; + if state != row.state { + set_state( + store, + &row.thread_id, + state, + refusal.map(Refusal::text), + ctx.now, + )?; + } + let fresh: Vec = acting + .iter() + .filter(|m| m.sender == worker) + .filter(|m| !is_served(store, &row.thread_id, &m.message_id).unwrap_or(true)) + .cloned() + .collect(); + if fresh.is_empty() { + return Ok(()); + } + match row.session.clone() { + Some(session) => { + let hop = orch::resolve_hop(ctx, store)?; + if deliver(ctx, store, &session, &row.thread_id, &fresh, &worker, hop) { + report.woke.push(session); + } + } + None => { + for m in &fresh { + mark_served(store, &row.thread_id, &m.message_id)?; + } + } + } + Ok(()) +} + +/// **Whether a working border thread has been quiet past its deadline** — [`DEADLINE_HOURS`] since +/// the last sign of life (6j6v.nj20, owner 2026-09-17: two hours of INACTIVITY, not two hours from +/// the start). A sign of life is a message on the thread, either side's, or an entry in the +/// transcript of the receiver's persona session — a persona that is reading and thinking is +/// working even while it has said nothing yet. +fn overdue(ctx: &Ctx, row: &BorderRow, acting: &[MessageRow]) -> bool { + let mut signs: Vec = acting.iter().filter_map(|m| m.created.clone()).collect(); + signs.push(row.updated.clone()); + if let Some(peers) = ctx.peers { + if let Ok(Ok(peer)) = find_one(peers, &row.peer) { + if let Ok(far) = open_far(&peer) { + let seen: Option = far + .store + .connection() + .query_row( + "SELECT MAX(t.at) FROM agent_transcript t + JOIN border_threads b ON b.session = t.internal_session + WHERE b.thread_id = ?1", + [&row.thread_id], + |r| r.get(0), + ) + .optional() + .ok() + .flatten() + .flatten(); + signs.extend(seen); + } + } + } + let Some(now) = epoch_secs(ctx.now) else { + return false; + }; + // A time from the future counts as now: the other side writes these, and an instant it dated + // ahead must not postpone the deadline. + let last = signs + .iter() + .filter_map(|s| epoch_secs(s)) + .map(|t| t.min(now)) + .max(); + last.is_some_and(|last| now - last > DEADLINE_HOURS * 3600) +} + +/// Seconds since the epoch of an RFC 3339 instant, or `None` for one that does not parse. +fn epoch_secs(instant: &str) -> Option { + time::OffsetDateTime::parse(instant, &time::format_description::well_known::Rfc3339) + .ok() + .map(|t| t.unix_timestamp()) +} + +/// **Take an outbound commission back** — the caller's owner withdrew the operation it belongs +/// to, or the receiver gave no sign of life past the deadline. The caller's coordinator says so on +/// the thread (marked, so the receiver stops its persona), clears the expectation so the caller is +/// no longer waiting on it, and carries it across. It wakes NOBODY: a withdrawal took the +/// commissioner back too, and the deadline's wake is [`wake_with_cancellation`], a call of its own so +/// that what a withdrawal can set going is visible in the code and not only in a branch. +fn cancel(ctx: &Ctx, store: &mut ChatStore, row: &BorderRow, why: &str) -> Result<()> { + // Said in the commissioner's name when its session still resolves; otherwise in the name of the + // coordinator running this — the receiver needs the marker either way, or its persona keeps + // working on a commission nobody here is waiting for any more (review of PR #14). + let role = row + .session + .as_deref() + .and_then(|s| store.session_role(s).ok().flatten()); + let author = role.clone().unwrap_or_else(|| ctx.actor.to_string()); + let caller = ctx.qualify(&author); + let body = format!("Withdrawn: {why}."); + let refs = Refs { + border: Some(BorderRef { + from: String::new(), + to: format!("{}/{}", row.peer, row.persona), + role, + hop: 0, + refusal: None, + canceled: true, + }), + ..Refs::default() + }; + let channel = store + .thread_channel(&row.thread_id) + .or_else(|| store.thread_channel_via_message(&row.thread_id)) + .unwrap_or_default(); + // Posted through the store and not `facade::send`: the write-surface gate joins calls by + // bare name (nxf 6j6v.cbhe), and a `send` here reads to it as the CLI's `send`, which + // starts sessions — a cancellation starts none. + store.set_wall_clock(ctx.now); + store.post_message(&crate::model::MessageEnvelope { + origin: ctx.origin.to_string(), + channel_id: channel, + sender: caller.clone(), + kind: MessageKind::Info, + priority: Priority::Normal, + disposition: Disposition::InTurn, + thread_id: Some(row.thread_id.clone()), + refs, + body, + }); + store.set_expects_reply_from(&row.thread_id, "[]", &caller); + set_state( + store, + &row.thread_id, + BorderState::Canceled, + Some(why), + ctx.now, + )?; + after_local_write_quiet(ctx, store, &row.thread_id); + Ok(()) +} + +/// Wake the commissioner of a border thread the deadline canceled, with the reason — resumed, or +/// held while it is mid-turn. +fn wake_with_cancellation( + ctx: &Ctx, + store: &mut ChatStore, + row: &BorderRow, + why: &str, +) -> Result<()> { + let Some(session) = row.session.as_deref() else { + return Ok(()); + }; + let caller = ctx.qualify(&store.session_role(session)?.unwrap_or_default()); + let body = format!("Withdrawn: {why}. Thread {} is closed.", row.thread_id); + let hop = orch::resolve_hop(ctx, store)?; + let guidance = orch::reply_guidance(store, ctx.now, &caller, Some(&row.thread_id)); + let wake = orch::wake_message(&body, true, &guidance); + let hold = crate::collecting::Held { + thread_id: row.thread_id.clone(), + message_id: String::new(), + sender: caller, + body, + escalated: true, + }; + let _ = orch::resume_return_address( + ctx, + store, + orch::Resume { + addr: session, + body: &wake, + model: None, + hop, + thread: Some(&row.thread_id), + on_busy: orch::OnBusy::Hold(&hold), + }, + ); + Ok(()) +} + +/// **Withdraw every outbound border thread of the operation rooted at `root`** — what taking back an +/// operation does at the border: the threads it sent into other workspaces are canceled there too, +/// and their personas stopped. Called by [`crate::orchestration::withdraw`]; best effort, since the +/// withdrawal itself has already happened. +pub fn withdraw_under(ctx: &Ctx, store: &mut ChatStore, root: &str) { + let Ok(open) = rows(store) else { return }; + for row in open + .into_iter() + .filter(|r| r.direction == Direction::Outbound && !r.state.is_final()) + { + if store.thread_root(&row.thread_id).ok().as_deref() != Some(root) { + continue; + } + if let Err(e) = cancel(ctx, store, &row, "withdrawn by the owner of the operation") { + eprintln!( + "warning: border thread {} could not be withdrawn across the border: {}", + row.thread_id, e.msg + ); + } + } + refresh_marker(store, ctx.db_path); +} + +/// Whether `store`'s border record names `thread`. +fn holds_record(store: &ChatStore, thread: &str) -> bool { + matches!(row(store, thread), Ok(Some(_))) +} + +/// Whether the other side already records this thread — the receiver holds it, so it is working. +fn peer_holds(ctx: &Ctx, row: &BorderRow) -> bool { + let Some(peers) = ctx.peers else { return false }; + let Ok(Ok(peer)) = find_one(peers, &row.peer) else { + return false; + }; + open_far(&peer) + .ok() + .and_then(|f| self::row(&f.store, &row.thread_id).ok().flatten()) + .is_some() +} + +/// Wake `session` with `messages` — resumed, or held while it is mid-turn — and mark them served. +/// `true` when the session was woken now. +fn deliver( + ctx: &Ctx, + store: &mut ChatStore, + session: &str, + thread: &str, + messages: &[MessageRow], + from: &str, + hop: orch::CheckedHop, +) -> bool { + // **Claimed before they are handed over** (review of PR #14): the claim is an insert that only + // one of several concurrent handovers can win, so a message is delivered once however many + // runs see it. A delivery that puts nothing in motion gives its claims back below. + let claimed: Vec<&MessageRow> = messages + .iter() + .filter(|m| mark_served(store, thread, &m.message_id).unwrap_or(false)) + .collect(); + let messages: Vec = claimed.into_iter().cloned().collect(); + let Some(last) = messages.last() else { + return false; + }; + let escalated = messages + .iter() + .any(|m| m.kind == crate::model::KIND_ESCALATION); + let body = messages + .iter() + .map(|m| m.body.as_str()) + .collect::>() + .join("\n\n"); + let handle = store + .session_role(session) + .ok() + .flatten() + .map(|r| ctx.qualify(&r)) + .unwrap_or_default(); + let guidance = orch::reply_guidance(store, ctx.now, &handle, Some(thread)); + let wake = orch::wake_message(&body, escalated, &guidance); + let hold = crate::collecting::Held { + thread_id: thread.to_string(), + message_id: last.message_id.clone(), + sender: from.to_string(), + body: body.clone(), + escalated, + }; + let attempt = orch::resume_return_address( + ctx, + store, + orch::Resume { + addr: session, + body: &wake, + model: None, + hop, + thread: Some(thread), + on_busy: orch::OnBusy::Hold(&hold), + }, + ); + // HELD — the session is mid-turn — counts as delivered: the held answer goes out when its turn + // ends. On the command line that takes the background service; without one it waits + // (nxf 6j6v.v0pj), exactly as an in-house consultation's held answer does. + if attempt.woke.is_none() && attempt.held.is_none() { + for m in &messages { + unmark_served(store, thread, &m.message_id); + } + } + attempt.woke.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A border thread this workspace commissioned, in an in-memory store: the direct conversation + /// between `local/pm` and the receiver's `xx12/pm`, and a thread in it expecting `xx12/pm`. + fn commissioned_thread(store: &mut ChatStore) -> &'static str { + let channel = + crate::orchestration::ensure_dm_channel(store, "local", "pm", "local/pm", "xx12/pm") + .unwrap(); + store.open_thread( + "t-border", + &crate::model::ThreadRoot { + origin: "local".into(), + channel_id: channel, + opener: "local/pm".into(), + created: "2026-10-03T10:00:00Z".into(), + parent: None, + }, + "local/pm", + ); + store.set_expects_reply_from("t-border", "[\"xx12/pm\"]", "local/pm"); + "t-border" + } + + #[test] + fn a_thread_this_workspace_commissioned_across_the_border_is_not_a_chat_with_its_own_persona() { + // The trap of nxf 6j6v.q32p in `persona_chat`: it reduces the expected participant to its + // last segment, so `xx12/pm` — the OTHER workspace's pm — read as this workspace's `pm`, which + // a pickup or a reply would then start to answer its own commission. + let defs = crate::definitions::Definitions::new( + vec![serde_yaml::from_str("handle: pm\nsystem_prompt: PM.\n").unwrap()], + vec![], + ) + .unwrap(); + let worker = crate::worker::DryWorker { log: None }; + let ctx = Ctx { + now: "2026-10-03T10:00:00Z", + origin: "local", + actor: "test", + session: None, + hop: 0, + defs: &defs, + worker: &worker, + timer: &crate::timer::DryTimer, + namer: &crate::naming::DryNamer, + db_path: "", + project_claude_md: None, + module_primes: None, + machines: None, + peers: None, + }; + let mut store = ChatStore::open_in_memory(1); + let thread = commissioned_thread(&mut store); + assert!( + crate::orchestration::persona_chat(&ctx, &store, thread) + .unwrap() + .is_some(), + "the premise: without its border record the thread reads as a chat with our pm" + ); + insert_row( + &store, + thread, + Direction::Outbound, + "test/beta", + "pm", + None, + ctx.now, + ) + .unwrap(); + assert!( + crate::orchestration::persona_chat(&ctx, &store, thread) + .unwrap() + .is_none(), + "a border thread this workspace commissioned is nobody's chat here" + ); + } + + #[test] + fn only_a_thread_in_the_direct_conversation_of_its_two_parties_is_copied() { + // Review of PR #14, Integrity #1: a thread whose log names another channel would carry that + // channel's ops and members across — one workspace writing into another's conversation. + let mut store = ChatStore::open_in_memory(1); + let thread = commissioned_thread(&mut store); + assert!( + thread_op_ids(&store, thread).is_ok(), + "the direct conversation is copied" + ); + + store.set_channel_field( + "c-team", + "kind", + crate::model::CHANNEL_KIND_GROUP, + "local/pm", + ); + store.add_member("c-team", "local/pm", "local/pm"); + store.add_member("c-team", "xx12/pm", "local/pm"); + store.open_thread( + "t-in-a-group", + &crate::model::ThreadRoot { + origin: "local".into(), + channel_id: "c-team".into(), + opener: "local/pm".into(), + created: "2026-10-03T10:00:00Z".into(), + parent: None, + }, + "local/pm", + ); + store.set_expects_reply_from("t-in-a-group", "[\"xx12/pm\"]", "local/pm"); + let err = thread_op_ids(&store, "t-in-a-group").expect_err("refused whole"); + assert!(err.msg.contains("direct conversation"), "{}", err.msg); + } + + #[test] + fn exactly_one_handover_wins_the_admission_of_a_commission() { + // Review of PR #14, Integrity #4: the service's pass, a `send` and the peer's child process + // can look at one commission at once; only the one that claims it starts a persona. + let store = ChatStore::open_in_memory(1); + insert_row( + &store, + "t-1", + Direction::Inbound, + "test/alpha", + "pm", + None, + "2026-10-03T10:00:00Z", + ) + .unwrap(); + assert!(claim_admission(&store, "t-1", "2026-10-03T10:00:01Z").unwrap()); + assert!(!claim_admission(&store, "t-1", "2026-10-03T10:00:01Z").unwrap()); + assert_eq!( + row(&store, "t-1").unwrap().unwrap().state, + BorderState::Working + ); + } + + #[test] + fn an_address_is_a_workspace_name_and_a_persona() { + assert_eq!( + parse_address("nxsflow/nexus-flow/pm"), + Some(Address { + workspace: "nxsflow/nexus-flow", + persona: "pm" + }) + ); + assert_eq!( + parse_address("group/sub/repo/pm").map(|a| a.workspace), + Some("group/sub/repo") + ); + } + + #[test] + fn a_handle_and_a_qualified_participant_are_not_addresses() { + for to in [ + "pm", + "ab12/pm", + "nxsflow/nexus-flow/__engine", + "nxsflow/nexus-flow/p m", + "nxsflow/nexus-flow/", + "NxsFlow/nexus-flow/pm", + "/x/pm", + ] { + assert_eq!(parse_address(to), None, "{to}"); + } + } + + #[test] + fn the_states_render_as_a2a_names_them() { + let names: Vec<&str> = [ + BorderState::Submitted, + BorderState::Working, + BorderState::InputRequired, + BorderState::Completed, + BorderState::Rejected, + BorderState::Canceled, + ] + .iter() + .map(|s| s.as_str()) + .collect(); + assert_eq!( + names, + [ + "submitted", + "working", + "input-required", + "completed", + "rejected", + "canceled" + ] + ); + } +} diff --git a/crates/chat/src/cli.rs b/crates/chat/src/cli.rs index be6084a..f73275f 100644 --- a/crates/chat/src/cli.rs +++ b/crates/chat/src/cli.rs @@ -534,6 +534,12 @@ enum Command { #[arg(long)] thread: String, }, + /// Carry this workspace's border threads across: copy each one's messages to and from the + /// other workspace on this machine, start a persona for a commission that came in and passes + /// its access check, and wake whoever an answer from the other side is for. Run by the + /// background service on its pass; `send` and `reply` on a border thread run it themselves. + #[command(hide = true)] + Handover, /// Print an embedded, offline guide. Omit the topic to list the available ones. /// /// One of the three building blocks' guide verbs (6j6v.9e3r): `nxc guide` serves CHAT's @@ -815,6 +821,18 @@ static MODULE_PRIMES: std::sync::OnceLock<&'static dyn crate::facade::ModulePrim static MACHINES: std::sync::OnceLock<&'static dyn crate::machine::Machines> = std::sync::OnceLock::new(); +/// **How this CLI reaches the other workspaces on the machine** (nxf 6j6v.4gp2) — set once by +/// [`provide_peers`] for [`MACHINES`]'s reason: the composition root knows the service registry +/// and the binary that runs a peer's handover. `None` for anyone linking this CLI on its own, which +/// refuses an address from another workspace. +static PEERS: std::sync::OnceLock<&'static dyn crate::border::Peers> = std::sync::OnceLock::new(); + +/// Hand this CLI the host's [`crate::border::Peers`] — what the `nxs` binary calls before +/// [`run_from_with`]. Keeps the FIRST one, like the other providers. +pub fn provide_peers(peers: &'static dyn crate::border::Peers) { + let _ = PEERS.set(peers); +} + /// [`run_from`] with the sibling modules' prime source injected — what the `nxs` multicall binary /// calls, because the composition root is the one place that knows the module registry. /// @@ -952,6 +970,7 @@ fn dispatch(cli: &Cli) -> Result<()> { stream, machine, } => { + refuse_another_db_for_a_session(db)?; let body = resolve_body(body.as_deref(), body_file.as_deref())?; reply_thread( cli.json, @@ -996,6 +1015,7 @@ fn dispatch(cli: &Cli) -> Result<()> { force, } => resume(cli.json, db, thread.as_deref(), session.as_deref(), *force), Command::Tick { thread } => tick(cli.json, db, thread), + Command::Handover => handover_verb(cli.json, db), Command::Guide { topic } => crate::guide::guide(cli.json, topic.as_deref()), } } @@ -1149,6 +1169,22 @@ fn cwd() -> Result { } /// Resolve the workspace and open chat's store over it. +/// **A spawned session answers in its own workspace** (nxf 6j6v.ewbj, review of PR #14). Its +/// coordinator stamps `NXC_DB` with that workspace's database; a `--db` naming another one would +/// let a persona whose only granted command is `nxc reply` write into a different workspace — +/// which the narrow grant exists to rule out. Refused before anything is read. A caller without a +/// session (a person, a test) keeps `--db` as it always was. +fn refuse_another_db_for_a_session(db: Option<&str>) -> Result<()> { + let session = std::env::var("NXC_SESSION").ok().filter(|s| !s.is_empty()); + let own = std::env::var("NXC_DB").ok().filter(|s| !s.is_empty()); + match (session, own) { + (Some(_), Some(own)) if db != Some(own.as_str()) => Err(NxfError::validation(format!( + "this session answers in its own workspace ({own}); `--db` may not name another one" + ))), + _ => Ok(()), + } +} + fn open(db: Option<&str>) -> Result { let ws = Workspace::resolve(db, &cwd()?)?; ws.open_chat_store() @@ -1226,7 +1262,7 @@ impl CliCtx { // destructive direction at every site that asks, the channel-advance gate of nxf // 6j6v.10yb included. worker: LazyWorker { cwd: root.clone() }, - timer: LazyTimer, + timer: LazyTimer::FROM_ENV, namer: LazyNamer, now: resolve_now()?, origin: origin_in(&ws), @@ -1255,6 +1291,7 @@ impl CliCtx { project_claude_md: self.project_claude_md.as_deref(), module_primes: self.module_primes, machines: MACHINES.get().copied(), + peers: PEERS.get().copied(), } } } @@ -1263,7 +1300,18 @@ impl CliCtx { /// same reason (PR #269 review, Code Quality #3): most CLI verbs never schedule anything, and an /// unknown `NXC_TIMER` value should be a loud error only for the ones that do, exactly as it was /// when `select_timer()` was called from inside the verb itself. -struct LazyTimer; +/// +/// `resolve` is [`crate::timer::select_timer`] everywhere but in the tests that hold this wrapper +/// to forwarding every method it has. +struct LazyTimer { + resolve: fn() -> Result>, +} + +impl LazyTimer { + const FROM_ENV: LazyTimer = LazyTimer { + resolve: crate::timer::select_timer, + }; +} /// Resolves the real namer from `NXC_NAMER` on FIRST USE — [`LazyTimer`]'s twin, lazy for its /// reason: most CLI verbs open no thread at all, and an unknown `NXC_NAMER` should be a loud error @@ -1286,10 +1334,28 @@ impl crate::timer::Timer for LazyTimer { deadline: &str, command: &str, ) -> Result { - crate::timer::select_timer()?.schedule(thread_id, deadline, command) + (self.resolve)()?.schedule(thread_id, deadline, command) + } + /// **Forwarded, not inherited** (nxf 6j6v.4gp2) — [`service_fault`](Self::service_fault)'s + /// lesson, learned a second time. The trait's default arms a delivery as an ordinary + /// `schedule` keyed on the SESSION, which the service backend books as a channel tick: the + /// service then ran `nxc tick --thread `, which answers "no such thread", and an + /// answer held for a busy caller was never handed over on the command line. Found when a + /// border answer arrived while its caller was still ending its turn. + fn schedule_delivery( + &self, + session: &str, + deadline: &str, + ) -> Result { + (self.resolve)()?.schedule_delivery(session, deadline) + } + /// Forwarded for [`schedule_delivery`](Self::schedule_delivery)'s reason: the default books the + /// way back from an availability boundary as a tick on a session id. + fn schedule_resume(&self, session: &str, deadline: &str) -> Result { + (self.resolve)()?.schedule_resume(session, deadline) } fn cancel(&self, handle: &crate::timer::TimerHandle) -> Result<()> { - crate::timer::select_timer()?.cancel(handle) + (self.resolve)()?.cancel(handle) } /// **Forwarded, not inherited** (nxf 6j6v.0j12). The trait defaults this to `None`, which is the /// right answer for a backend that does not depend on the service — and the wrong one here, @@ -1300,7 +1366,7 @@ impl crate::timer::Timer for LazyTimer { /// An unresolvable `NXC_TIMER` is `None` rather than an error: this rides along on calls that /// have already succeeded, and the verb that genuinely needs the timer says so loudly on its own. fn service_fault(&self) -> Option { - crate::timer::select_timer().ok()?.service_fault() + (self.resolve)().ok()?.service_fault() } } @@ -2574,6 +2640,33 @@ fn withdraw(json: bool, db: Option<&str>, thread: &str) -> Result<()> { /// looks at the thread at all, it reclaims this workspace's working-tree lease if that lease is /// past its bound and somebody is parked behind it. See `orchestration::sweep_expired_working_tree` /// for why the drain rides this verb rather than one of its own. +/// `nxc handover` (nxf 6j6v.4gp2) — see [`crate::border::hand_over`]. +fn handover_verb(json: bool, db: Option<&str>) -> Result<()> { + let cli_ctx = CliCtx::resolve(db, hop())?; + let mut store = open(db)?; + let r = crate::border::hand_over(&cli_ctx.ctx(), &mut store)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&r).map_err(|e| NxfError::io(e.to_string()))? + ); + return Ok(()); + } + for t in &r.admitted { + println!("admitted {t}"); + } + for (t, why) in &r.refused { + println!("refused {t}: {}", why.text()); + } + for s in &r.woke { + println!("woke {s}"); + } + for (t, e) in &r.failed { + println!("failed {t}: {e}"); + } + Ok(()) +} + fn tick(json: bool, db: Option<&str>, thread_id: &str) -> Result<()> { let cli_ctx = CliCtx::resolve(db, hop())?; let mut store = open(db)?; @@ -3565,14 +3658,37 @@ fn status_line(t: &crate::facade::StatusThread) -> String { None => t.channel_id.clone().unwrap_or_default(), }; format!( - "{indent}{} {what} {state}{by_whom}{own_round}{}{}{}", + "{indent}{} {what} {state}{by_whom}{own_round}{}{}{}{}", t.thread_id, + border_suffix(t.border.as_ref()), session_suffix(t), interruption_suffix(t.interrupted.as_ref()), anchor_suffix(t.working_copy.as_ref()) ) } +/// **The thread crosses into another workspace** (nxf 6j6v.szc5) — which one, which way, and where +/// it stands, in the four words A2A uses. Empty for every thread that stays here. +fn border_suffix(border: Option<&crate::facade::BorderStatus>) -> String { + let Some(b) = border else { + return String::new(); + }; + let way = match b.direction { + crate::border::Direction::Outbound => "to", + crate::border::Direction::Inbound => "from", + }; + let reason = b + .reason + .as_deref() + .map(|r| format!(" ({r})")) + .unwrap_or_default(); + format!( + " — across the border {way} {}: {}{reason}", + b.peer, + b.state.as_str() + ) +} + /// **The session working here is on hold because the model went away** (nxf 6j6v.npy3) — the /// terminal half of [`crate::facade::StatusThread::interrupted`], and empty for every thread that is /// not, which is almost all of them. @@ -4316,6 +4432,49 @@ fn print_transcript_entry(e: &crate::facade::TranscriptEntryView, depth: usize) #[cfg(test)] mod tests { + // ---- the CLI's timer forwards every method (nxf 6j6v.4gp2) ----------------------------- + + /// Records which trait method a call arrived through. + struct Recording; + + static SEEN: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + + impl crate::timer::Timer for Recording { + fn schedule(&self, _: &str, _: &str, _: &str) -> Result { + SEEN.lock().unwrap().push("schedule"); + Ok(crate::timer::TimerHandle("h".into())) + } + fn schedule_delivery(&self, _: &str, _: &str) -> Result { + SEEN.lock().unwrap().push("delivery"); + Ok(crate::timer::TimerHandle("h".into())) + } + fn schedule_resume(&self, _: &str, _: &str) -> Result { + SEEN.lock().unwrap().push("resume"); + Ok(crate::timer::TimerHandle("h".into())) + } + fn cancel(&self, _: &crate::timer::TimerHandle) -> Result<()> { + Ok(()) + } + } + + #[test] + fn the_cli_timer_hands_a_delivery_and_a_resume_to_the_backend_as_what_they_are() { + // The trait's defaults turn both into a plain `schedule` keyed on a session id, which the + // service backend books as a tick on a thread that does not exist. The wrapper the CLI runs + // every verb with must reach the backend's OWN method, or no held answer is ever delivered. + use crate::timer::Timer as _; + let timer = super::LazyTimer { + resolve: || Ok(std::sync::Arc::new(Recording)), + }; + timer + .schedule_delivery("m-session", "2026-10-03T00:00:00Z") + .unwrap(); + timer + .schedule_resume("m-session", "2026-10-03T00:00:00Z") + .unwrap(); + assert_eq!(*SEEN.lock().unwrap(), vec!["delivery", "resume"]); + } + use super::*; /// An anchor as the engine records one — full sha, a real branch, and a fingerprint. diff --git a/crates/chat/src/collecting.rs b/crates/chat/src/collecting.rs index a02ba1f..99d5c05 100644 --- a/crates/chat/src/collecting.rs +++ b/crates/chat/src/collecting.rs @@ -134,6 +134,18 @@ impl ChatStore { Ok(rows) } + /// Every thread an answer is held from — what keeps a sub-round "in flight" on the status + /// row of the thread that commissioned it until the answer reaches its commissioner + /// (nxf 6j6v.4gp2). + pub fn threads_holding_wakes(&self) -> Result> { + let conn = self.connection(); + let mut st = conn.prepare("SELECT DISTINCT thread_id FROM pending_wakes")?; + let rows = st + .query_map([], |r| r.get(0))? + .collect::>>()?; + Ok(rows) + } + /// Every session with something waiting for it, in a deterministic order — what the periodic /// sweep walks when a caller died without announcing its end. pub fn sessions_holding_wakes(&self) -> Result> { diff --git a/crates/chat/src/engine.rs b/crates/chat/src/engine.rs index 288856f..d3e3bf5 100644 --- a/crates/chat/src/engine.rs +++ b/crates/chat/src/engine.rs @@ -158,6 +158,14 @@ pub struct EngineConfig { /// existed. A host that executes passes its machine and its claims; a host that only shows and /// writes (a web board) passes presence and no machine, and is then asked to name one. pub machines: Option>, + /// **How this handle's host reaches the other workspaces on its machine** (nxf 6j6v.4gp2) — see + /// [`crate::border::Peers`]. + /// + /// `None` (the default) reaches none: an address from another workspace + /// (`//`) is refused and every other call behaves exactly as before the + /// border existed. A host that passes one can commission across the border and runs + /// [`Engine::hand_over`] for this workspace. + pub peers: Option>, } /// **Hand-written since nxf 6j6v.k8zq**, because [`EngineConfig::module_primes`] is a trait object @@ -181,6 +189,11 @@ impl PartialEq for EngineConfig { (Some(a), Some(b)) => Arc::ptr_eq(a, b), _ => false, } + && match (&self.peers, &other.peers) { + (None, None) => true, + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + _ => false, + } } } @@ -193,6 +206,7 @@ impl Default for EngineConfig { poll_interval: nxs_foundation::watch::POLL_INTERVAL, module_primes: None, machines: None, + peers: None, } } } @@ -218,6 +232,8 @@ pub struct Engine { module_primes: Option>, /// ([`EngineConfig::machines`]) — held for the handle's lifetime, like the prime source. machines: Option>, + /// ([`EngineConfig::peers`]) — held for the handle's lifetime, like the machines. + peers: Option>, } /// chat's store factory for the foundation handle: open the chat store (message reducer + the six @@ -352,6 +368,7 @@ impl Engine { namer, module_primes: cfg.module_primes, machines: cfg.machines, + peers: cfg.peers, }) } @@ -934,6 +951,10 @@ impl Engine { .machines .as_deref() .map(|m| m as &dyn crate::machine::Machines), + peers: self + .peers + .as_deref() + .map(|p| p as &dyn crate::border::Peers), }, ); f(&ctx, &mut s.store) @@ -962,6 +983,19 @@ impl Engine { self.with_orchestration(caller, |ctx, store| surface::send_to(ctx, store, req)) } + /// **Carry this workspace's border threads across** (`nxc handover`, nxf 6j6v.4gp2) — copy each + /// border thread to and from the other workspace on this machine, admit or refuse a commission + /// that came in (starting the persona it is for), and wake whoever an answer from the other side + /// is for. What the background service runs on its pass, and what a host that executes calls + /// when it learns the other side wrote. Reaches nothing without [`EngineConfig::peers`]. + pub fn handover(&self, caller: Caller<'_>) -> Result { + // A closure on purpose, not the function path clippy suggests: the write-surface gate + // (`tests/read_surface.rs`) finds what a method starts by its CALLS, and a function passed + // as a value is no call — this method would read as starting nothing. + #[allow(clippy::redundant_closure)] + self.with_orchestration(caller, |ctx, store| crate::border::hand_over(ctx, store)) + } + /// **Where a chat runs, before anything is sent** (`nxc machine`, nxf 6j6v.1c6k) — the machine /// a new chat with a persona would run on, or the one an existing chat runs on; where that came /// from (this call's choice, the chat, the persona's `machine:`, or the machine asking); whether @@ -1002,6 +1036,10 @@ impl Engine { .machines .as_deref() .map(|m| m as &dyn crate::machine::Machines), + peers: self + .peers + .as_deref() + .map(|p| p as &dyn crate::border::Peers), }, ); surface::machine(&ctx, &s.store, query) diff --git a/crates/chat/src/facade.rs b/crates/chat/src/facade.rs index 5414e29..21c19da 100644 --- a/crates/chat/src/facade.rs +++ b/crates/chat/src/facade.rs @@ -751,6 +751,43 @@ pub struct StatusThread { /// statement for the whole report, never one per thread. #[serde(skip_serializing_if = "Option::is_none")] pub interrupted: Option, + /// **This thread crosses the border of the workspace** (nxf 6j6v.szc5): which workspace is on + /// the other side, which way it was commissioned, and where it stands — the four states A2A + /// knows. The same thread carries it in BOTH workspaces, each from its own side. `None` for every + /// thread that stays in this workspace, which is skipped in `--json`. + #[serde(skip_serializing_if = "Option::is_none")] + pub border: Option, +} + +/// **A border thread as a status row shows it** (nxf 6j6v.szc5). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct BorderStatus { + /// The other workspace, by name (`/`). + pub peer: String, + /// `outbound` when this workspace commissioned, `inbound` when it was commissioned. + pub direction: crate::border::Direction, + pub state: crate::border::BorderState, + /// Why it was rejected or canceled. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl BorderStatus { + /// This workspace was commissioned. + pub fn is_inbound(&self) -> bool { + self.direction == crate::border::Direction::Inbound + } + + /// The other side still has it, or it is waiting on this one: neither finished nor refused. + pub fn is_in_progress(&self) -> bool { + matches!( + self.state, + crate::border::BorderState::Submitted + | crate::border::BorderState::Working + | crate::border::BorderState::InputRequired + ) + } } /// One operation: a root thread and everything that was opened out of it, across channel borders. @@ -3634,6 +3671,17 @@ pub fn status( // table that holds at most one row per claim, plus a worker call per session of the ONE claim // that wins attribution. let withdrawn = withdrawn_holders_for(store, worker, &id_list, &forest.root_of)?; + // Answers held for a commissioner still mid-turn (nxf 6j6v.4gp2) — the twelfth read, one + // statement over `pending_wakes`, which is empty unless an answer is on its way to a busy + // session. It keeps such a sub-round "in flight" on its parent's row; see + // [`crate::awaiting::own_sub_round_in_flight`]. + let held_answers = store.threads_holding_wakes()?; + // The border record (nxf 6j6v.szc5) — the thirteenth read, one statement over a device-local + // table that holds a row per border thread this workspace ever had. + let borders: BTreeMap = crate::border::rows(store)? + .into_iter() + .map(|r| (r.thread_id.clone(), r)) + .collect(); // **One bundle, because these seven ARE one thing**: the bulk reads this report is assembled // from, taken once for the whole selection and then read per thread. Passing them // individually made the per-thread derivation an eight-argument call, and the argument list @@ -3648,6 +3696,8 @@ pub fn status( working_tree: &working_tree, anchors: &anchors, interruptions: &interruptions, + held_answers: &held_answers, + borders: &borders, }; let mut operations = Vec::new(); @@ -3663,7 +3713,17 @@ pub fn status( // Both derived from the SAME `threads` this operation already carries, so a reader can // always find the thread behind either flag and the two can never disagree with the rows // beneath them. - let needs_decision = threads.iter().any(|t| t.escalated); + // **A question asked across the border is not a decision for THIS workspace** (nxf + // 6j6v.4gp2): when the persona working on an inbound border thread hands it back, the one + // who has to answer is its commissioner in the other workspace — never the owner here, who + // would otherwise be told NEEDS DECISION about somebody else's conversation. The thread + // still keeps the operation live while the other side has it (`border_live` below). + let needs_decision = threads + .iter() + .any(|t| t.escalated && !t.border.as_ref().is_some_and(BorderStatus::is_inbound)); + let border_live = threads + .iter() + .any(|t| t.border.as_ref().is_some_and(BorderStatus::is_in_progress)); let holds_working_tree = threads .iter() .any(|t| t.working_tree == Some(WorkingTreeStatus::Holding)); @@ -3697,6 +3757,7 @@ pub fn status( let withdrawn_here = withdrawn.get(root).cloned(); let live = open > 0 || needs_decision + || border_live || holds_working_tree || dead_end || interrupted @@ -3758,6 +3819,10 @@ struct DerivedFacts<'a> { /// thread for [`DerivedFacts::session_states`]'s reason: it is a fact about the session named on /// the row, so the two cannot come to disagree about one id. interruptions: &'a BTreeMap, + /// The threads whose answer is held for a commissioner still mid-turn (nxf 6j6v.4gp2). + held_answers: &'a std::collections::HashSet, + /// The border record, per thread (nxf 6j6v.szc5). + borders: &'a BTreeMap, } /// **How many process questions ONE status read will ask** (review of PR #444, Integrity #1). @@ -4296,6 +4361,8 @@ impl<'a> Forest<'a> { working_tree, anchors, interruptions, + held_answers, + borders, } = facts; let q = by_id.get(id)?; let parent = self.declared.get(id).copied().flatten(); @@ -4333,7 +4400,13 @@ impl<'a> Forest<'a> { parent: parent.map(str::to_string), depth: self.depth_of(id), state, - awaiting_human: is_root && q.complete, + // The root of an INBOUND border thread is answered to the commissioner in the other + // workspace, not to anybody here (nxf 6j6v.4gp2). + awaiting_human: is_root + && q.complete + && !borders + .get(id) + .is_some_and(|r| r.direction == crate::border::Direction::Inbound), escalated: last_replies.escalated.contains(id), substituted: last_replies.substituted.contains(id), opener: q.opener.clone(), @@ -4355,9 +4428,10 @@ impl<'a> Forest<'a> { // the forest's own edges and their quorums are in the same bulk read as this thread's, // so the field costs no statement at all — the property `tests/bulk_quorum.rs` // measures. A child of another operation cannot appear here: `kids` are this forest's. - waiting_on_sub_round: crate::awaiting::own_open_sub_round( + waiting_on_sub_round: crate::awaiting::own_sub_round_in_flight( q, kids.iter().filter_map(|kid| by_id.get(kid).copied()), + held_answers, ), working_copy: anchors.get(id).cloned(), // Keyed by SESSION for `session_state`'s reason, and beside it: the hold reported is a @@ -4367,6 +4441,12 @@ impl<'a> Forest<'a> { .get(id) .and_then(|sid| interruptions.get(sid)) .cloned(), + border: borders.get(id).map(|r| BorderStatus { + peer: r.peer.clone(), + direction: r.direction, + state: r.state, + reason: r.reason.clone(), + }), }) } } diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 9605479..7c317c3 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -35,6 +35,7 @@ pub mod anchor; /// hand back, and the human lines that say the same thing. Not CLI-gated: the block is a receipt /// field an embedding app reads exactly as `nxc --json` does. pub mod awaiting; +pub mod border; pub mod channel; /// **The persona coordinator's pending queue** (nxf 6j6v.gn8b): the durable hold that turns "your /// caller is busy, so nobody was woken" into "your caller is resumed with this, and with everything diff --git a/crates/chat/src/model.rs b/crates/chat/src/model.rs index aace7aa..6e73420 100644 --- a/crates/chat/src/model.rs +++ b/crates/chat/src/model.rs @@ -415,6 +415,12 @@ pub struct Refs { /// [`substituted`]: Refs::substituted #[serde(default, skip_serializing_if = "Option::is_none")] pub working_copy: Option, + /// **A commission across the border of a workspace, and who sent it** (nxf 6j6v.4gp2) — the + /// caller's workspace, the address, the role its coordinator stamped and the depth of its chain; + /// on the receiver's refusal, the reason. Part of what the author's key signs, so the receiver + /// reads it as the caller's coordinator wrote it. See [`crate::border`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border: Option, } /// The immutable message payload carried in one op's `value` (spec §2.1). `message_id`/`created` diff --git a/crates/chat/src/orchestration.rs b/crates/chat/src/orchestration.rs index 114b792..6ce08c7 100644 --- a/crates/chat/src/orchestration.rs +++ b/crates/chat/src/orchestration.rs @@ -121,6 +121,11 @@ pub struct Ctx<'a> { /// designated, and a chat starts where it is written, exactly as before the executing machine /// existed. pub machines: Option<&'a dyn crate::machine::Machines>, + /// **How this host reaches the other workspaces on this machine** (nxf 6j6v.4gp2) — see + /// [`crate::border::Peers`]. `None` — the default for a host that supplies none, and for every + /// test that does not care — reaches none: an address from another workspace is refused, and + /// everything else runs exactly as before the border existed. + pub peers: Option<&'a dyn crate::border::Peers>, } /// **Who is calling, and nothing that can be derived from that** (nxf 6j6v.07me): [`Ctx`]'s caller @@ -284,6 +289,8 @@ pub struct Adapter<'a> { pub project_claude_md: Option<&'a str>, pub module_primes: Option<&'a dyn crate::facade::ModulePrimes>, pub machines: Option<&'a dyn crate::machine::Machines>, + /// See [`Ctx::peers`]. + pub peers: Option<&'a dyn crate::border::Peers>, } impl<'a> Caller<'a> { @@ -350,6 +357,7 @@ impl<'a> Caller<'a> { project_claude_md: adapter.project_claude_md, module_primes: adapter.module_primes, machines: adapter.machines, + peers: adapter.peers, } } } @@ -797,6 +805,28 @@ pub fn trigger_role( priority, queued_since, } = req; + // **A session woken on the way back still owes what it owed** (nxf 6j6v.4gp2). An answer + // travelling UP a chain (`Unwind`: a resumed return address, a completed quorum handed to its + // opener) declares no obligation of its own, so `reply_thread` arrives `None` — but the woken + // session usually still owes the thread it was commissioned on, and the wake message names it + // with the command it takes. Without the obligation here the trigger granted nothing, and a + // persona that declares no `tools:` was woken with the answer and then refused its own + // `nxc reply` ("This command requires approval"); found when a border answer woke a PM that + // could not pass it on. Read back from the record — the thread where this persona is still + // outstanding — exactly as `fire_queued_trigger` reproduces a parked obligation. + let owed_on_the_way_back = match (reply_thread, chain) { + (None, ChainMove::Unwind) => { + crate::addressees::open_for(store, &ctx.qualify(&decl.handle), thread, ctx.now) + .ok() + .and_then(|open| { + open.into_iter() + .find(|a| a.relation == crate::addressees::Relation::Owed) + .map(|a| a.thread_id) + }) + } + _ => None, + }; + let reply_thread = reply_thread.or(owed_on_the_way_back.as_deref()); // ONE number, derived once, for both consumers: what is recorded against the session and what // the session is told in `NXC_HOP`. `resolve_hop` takes the larger of the two later, so letting // them be computed apart is letting the higher of two independent expressions silently win. @@ -4463,11 +4493,19 @@ fn fire_queued_trigger( /// declaration says — see that field for why the two lists are separate, and for what the sidecar /// does with each. A trigger that demands nothing grants nothing, so a role that owes nobody an /// answer reads exactly as it did before this existed. +/// +/// **What it grants is the command, not the shell** (nxf 6j6v.ewbj). It granted the bare `Bash` +/// until then, which the sidecar auto-approves — so a role declared `tools: []` that owed a reply +/// could run any command at all. [`crate::role::obligation_grant`] now decides from the +/// declaration: a declared list gets `Bash(nxc reply:*)` and nothing wider; only a role that +/// declared no `tools:` keeps the bare tool it always had. fn grant_the_means_for_the_obligation(req: &mut TriggerRequest) { if req.reply_thread.is_none() { return; } - let tool = crate::role::REPLY_OBLIGATION_TOOL; + let Some(tool) = crate::role::obligation_grant(req.role.tools.as_deref()) else { + return; + }; if !req.role.granted_tools.iter().any(|t| t == tool) { req.role.granted_tools.push(tool.to_string()); } @@ -4608,6 +4646,12 @@ impl CheckedHop { /// The bare cap check over an already-resolved depth, and deliberately still `u32 -> u32`: see /// [`CheckedHop`] for why this one may not mint one. Verbs call [`resolve_hop`], which is where the /// depth comes FROM; this stays separate so the cap and its wording live in exactly one place. +/// A depth carried in from outside this workspace — a border commission's (nxf 6j6v.4gp2) — put +/// through the same guard, so it reaches [`trigger_role`] only as a [`CheckedHop`]. +pub(crate) fn checked_hop(hop: u32) -> Result { + check_depth_guard(hop).map(CheckedHop) +} + pub fn check_depth_guard(hop: u32) -> Result { if hop > MAX_HOP { return Err(NxfError::validation(format!( @@ -4740,7 +4784,7 @@ fn ensure_declared_channel( /// address a triggered role's eventual reply routes back through. Only when a REAL session exists: /// a bare-terminal kickoff leaves it unset rather than reusing a placeholder that could later be /// mistaken for a genuine identity. -fn with_return_address(ctx: &Ctx, mut refs: Refs) -> Refs { +pub(crate) fn with_return_address(ctx: &Ctx, mut refs: Refs) -> Refs { if refs.session_id.is_none() { if let Some(s) = ctx.session { refs.session_id = Some(s.to_string()); @@ -5605,6 +5649,13 @@ pub(crate) fn persona_chat( let [persona] = q.expects.as_slice() else { return Ok(None); }; + // **A border thread this workspace commissioned is not a chat with one of its own personas** + // (nxf 6j6v.q32p). It expects a reply from the receiver's participant, `/pm`, and + // the reduction one line down would read that as this workspace's own `pm` — which would then + // be started here to answer its own commission. + if crate::border::is_outbound(store, thread) { + return Ok(None); + } let handle = persona.rsplit('/').next().unwrap_or(persona).to_string(); if ctx.defs.role(&handle).is_err() { return Ok(None); @@ -6728,6 +6779,7 @@ fn as_supervisor<'a>(ctx: &Ctx<'a>, hop: CheckedHop) -> Ctx<'a> { project_claude_md: ctx.project_claude_md, module_primes: ctx.module_primes, machines: ctx.machines, + peers: ctx.peers, } } @@ -9833,6 +9885,11 @@ pub struct ReplyReceipt { /// receipt of a chat that runs where it was written is byte-identical to before. #[serde(skip_serializing_if = "Option::is_none")] pub handed_to: Option, + /// **The reply landed on a border thread** (nxf 6j6v.4gp2): nothing is woken here — the party it + /// is for lives in the other workspace — and it was carried across at once; this is where the + /// border thread stands afterwards. Skipped when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub border: Option, #[serde(rename = "await")] pub await_: Option, } @@ -10703,6 +10760,7 @@ pub fn reply(ctx: &Ctx, store: &mut ChatStore, req: ReplyRequest) -> Result Result Result Result Result Result Result { + let inside = match (personas.as_slice(), humans) { + ([], false) => "nobody in this workspace".to_string(), + ([], true) => "only the person at the terminal".to_string(), + (named, false) => format!("only {}", named.join(", ")), + (named, true) => { + format!("only {} and the person at the terminal", named.join(", ")) + } + }; + Some(format!( + "{inside} may message you directly, and from other workspaces on this machine \ + {}{}", + external.join(", "), + via("; everyone else reaches you through") + )) + } }; if let Some(reachable) = reachable { lines.push(format!("- **Reachable:** {reachable}")); diff --git a/crates/chat/src/role.rs b/crates/chat/src/role.rs index fe5fe3d..640d820 100644 --- a/crates/chat/src/role.rs +++ b/crates/chat/src/role.rs @@ -258,6 +258,45 @@ pub enum Addressable { /// exists at all (nxf 6j6v.st83's second finding — until now a persona handle written here was /// silently read as a channel name and nothing complained until somebody tried to send). ViaChannels(Vec), + /// [`Only`](Addressable::Only) with the third class beside it (nxf 6j6v.t5xb): callers from + /// OTHER workspaces on this machine, by workspace and role — `external: ["*/pm", + /// "nxsflow/manufakt-io/pm"]`. `*` stands for every workspace this one trusts; the role is + /// always named. + /// + /// A variant of its own rather than a third field on `Only`, because `Only` is matched on by + /// field in code that embeds this crate, and a field added to it would break every such match; + /// a variant is additive under `#[non_exhaustive]`. The YAML is one shape: a mapping without + /// `external` reads as `Only`, one with it as this. + /// + /// No `external` means closed to the outside — under every other form too, `General` included: + /// "everybody" means everybody in this workspace. + OnlyWithExternal { + personas: Vec, + humans: bool, + external: Vec, + }, +} + +/// **Whether `rule` is an `external` entry** (nxf 6j6v.t5xb): `/`, the workspace a +/// name (`nxs_foundation::workspace_name`) or `*`, the role a handle. An `Err` says what is wrong. +pub fn check_external_rule(rule: &str) -> std::result::Result<(), String> { + let Some((workspace, role)) = rule.rsplit_once('/') else { + return Err(format!( + "`{rule}` is not an `external` entry — write `//`, or `*/` for \ + every trusted workspace" + )); + }; + if role.is_empty() || role == "*" { + return Err(format!( + "`{rule}` names no role — `external` admits a role, never everyone" + )); + } + if workspace != "*" && !nxs_foundation::workspace_name::is_valid_name(workspace) { + return Err(format!( + "`{workspace}` in `{rule}` is not a workspace name — it is `/` or `*`" + )); + } + Ok(()) } impl Addressable { @@ -270,20 +309,47 @@ impl Addressable { match self { Addressable::General => true, Addressable::Nobody | Addressable::ViaChannels(_) => false, - Addressable::Only { personas, humans } => match caller.persona() { + Addressable::Only { personas, humans } + | Addressable::OnlyWithExternal { + personas, humans, .. + } => match caller.persona() { None => *humans, Some(handle) => personas.iter().any(|p| p == handle), }, } } + /// The `external` entries, empty for every form that has none — which is every form but one. + pub fn external(&self) -> &[String] { + match self { + Addressable::OnlyWithExternal { external, .. } => external, + _ => &[], + } + } + + /// **Whether a persona of ANOTHER workspace may commission this one** (nxf 6j6v.t5xb): the + /// caller's workspace `from` and its `role` match an `external` entry. Only the role half of the + /// two checks — whether `from` is trusted at all is the trust list's question, asked first. + /// + /// A bare handle in `personas:` never admits anybody from outside: it means this workspace's + /// own persona of that name, and nothing here reads it. + pub fn admits_external(&self, from: &str, role: &str) -> bool { + self.external().iter().any(|rule| { + rule.rsplit_once('/') + .is_some_and(|(workspace, r)| r == role && (workspace == "*" || workspace == from)) + }) + } + /// Whether ANY caller may open a direct conversation — what a reader needs when there is no /// caller to ask about, such as a catalogue rendered for nobody in particular. pub fn allows_direct_from_anyone(&self) -> bool { match self { Addressable::General => true, Addressable::Nobody | Addressable::ViaChannels(_) => false, - Addressable::Only { personas, humans } => *humans || !personas.is_empty(), + Addressable::Only { personas, humans } + | Addressable::OnlyWithExternal { + personas, humans, .. + } => *humans || !personas.is_empty(), } } @@ -302,7 +368,9 @@ impl Addressable { /// check walks. pub fn named_personas(&self) -> &[String] { match self { - Addressable::Only { personas, .. } => personas, + Addressable::Only { personas, .. } | Addressable::OnlyWithExternal { personas, .. } => { + personas + } _ => &[], } } @@ -322,6 +390,13 @@ impl Addressable { /// would parse as an empty whitelist and silently mean "nobody", which is the exact class of quiet /// misconfiguration nxf 6j6v.st83 exists to end. With it the untagged enum finds no variant and the /// role file fails to load, naming itself. +/// +/// **And it stays strict with `external` added (nxf 6j6v.t5xb), at a known price.** An engine older +/// than `external` refuses a declaration that carries it — the whole persona file fails to load +/// there. Relaxing the check now would not help those engines, which are already built, and it +/// would give up the typo protection for every key to come. So a declaration with `external:` goes +/// only into a repository whose every reader — the apps that pin an engine included — has an engine +/// that knows the key; the personas guide and the changelog say so. #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct OnlyRaw { @@ -329,6 +404,8 @@ struct OnlyRaw { personas: Vec, #[serde(default)] humans: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + external: Vec, } impl Serialize for Addressable { @@ -342,6 +419,17 @@ impl Serialize for Addressable { Addressable::Only { personas, humans } => OnlyRaw { personas: personas.clone(), humans: *humans, + external: Vec::new(), + } + .serialize(serializer), + Addressable::OnlyWithExternal { + personas, + humans, + external, + } => OnlyRaw { + personas: personas.clone(), + humans: *humans, + external: external.clone(), } .serialize(serializer), Addressable::ViaChannels(channels) => channels.serialize(serializer), @@ -372,10 +460,20 @@ impl<'de> Deserialize<'de> for Addressable { `personas:`/`humans:`, or a list of channel names" ))), Raw::List(channels) => Ok(Addressable::ViaChannels(channels)), - Raw::Only(only) => Ok(Addressable::Only { + Raw::Only(only) if only.external.is_empty() => Ok(Addressable::Only { personas: only.personas, humans: only.humans, }), + Raw::Only(only) => { + for rule in &only.external { + check_external_rule(rule).map_err(serde::de::Error::custom)?; + } + Ok(Addressable::OnlyWithExternal { + personas: only.personas, + humans: only.humans, + external: only.external, + }) + } } } } @@ -917,19 +1015,58 @@ fn push_paragraph(block: &mut String, paragraph: &str) { block.push_str(paragraph); } -/// **The tool a session needs to do what [`reply_obligation`] tells it to do** (nxf 6j6v.kffm). +/// **The tool a session needs to do what [`reply_obligation`] tells it to do** (nxf 6j6v.kffm), +/// scoped to that one command (nxf 6j6v.ewbj). /// -/// Every form the obligation offers is a SHELL COMMAND — `nxc reply …` — so the means is the tool -/// that runs a shell command. Named here rather than at the seam that grants it, beside the text -/// that imposes the duty, because the two are one decision: change the obligation into something -/// that is not a command line and this constant is wrong in the same edit. +/// Every form the obligation offers is a SHELL COMMAND — `nxc reply …` — so the means is the shell +/// tool, and only for that command. Named here rather than at the seam that grants it, beside the +/// text that imposes the duty, because the two are one decision: change the obligation into +/// something that is not a command line and this constant is wrong in the same edit. /// `role::tests::the_obligation_asks_for_a_shell_command_and_the_granted_tool_runs_one` is what /// holds them together. /// -/// Consumed by `crate::orchestration`'s trigger funnel, which unions it into -/// [`RoleSpec::granted_tools`](crate::worker::RoleSpec::granted_tools) whenever a trigger carries a -/// `reply_thread`. -pub const REPLY_OBLIGATION_TOOL: &str = "Bash"; +/// **Why scoped, and what the scope lets through.** Until 6j6v.ewbj this was the bare `"Bash"`, and +/// the sidecar put it on the auto-approval list — so a persona declared with `tools: []`, the state +/// the guide sells as "it cannot act", got an unscoped, auto-approved shell the moment it owed a +/// reply: `gh pr merge`, `git push --force`, a release. Measured against the pinned agent SDK +/// (0.3.215, 2026-10-03), this rule lets the obligation's own forms through — the heredoc form and +/// the one-line `--escalate` argument — and refuses `nxc send`, `touch`, `rm`, `curl`, `gh`, `env`, +/// a chain (`&&`, `;`), a pipe, a redirection and a `$(…)` inside the argument. What the SDK itself +/// treats as read-only inside the session's working directory (`ls`, `echo`, `git status`, `cat` +/// of a file there) still runs without approval: that allowance is the SDK's, not this rule's. +/// +/// Consumed by `crate::orchestration`'s trigger funnel through [`obligation_grant`], which decides +/// per declaration whether this, the bare tool, or nothing is granted. +pub const REPLY_OBLIGATION_TOOL: &str = "Bash(nxc reply:*)"; + +/// **The tool [`REPLY_OBLIGATION_TOOL`] scopes** — what the sidecar's base toolset must contain for +/// the scoped rule to mean anything (the SDK's `tools` list takes tool names, its `allowedTools` +/// list takes rules). +pub const SHELL_TOOL: &str = "Bash"; + +/// **What an obligated session is granted, given what its author declared** (nxf 6j6v.kffm, +/// 6j6v.ewbj) — `None` when the declaration already covers the obligation. +/// +/// - `tools` absent: the bare [`SHELL_TOOL`], exactly as before 6j6v.ewbj. An undeclared role runs +/// on the SDK's full default toolset, and the roles written that way (a PM reading its board +/// through `nxf`) rely on the approved shell; scoping it here would break them without closing +/// anything the declaration promised to close. +/// - a list naming the bare shell: nothing — the author already approved every command. +/// - any other list, `[]` included: [`REPLY_OBLIGATION_TOOL`], the obligation's command and nothing +/// else. A list that already carries that rule gets nothing added. +pub fn obligation_grant(declared: Option<&[String]>) -> Option<&'static str> { + match declared { + None => Some(SHELL_TOOL), + Some(list) + if list + .iter() + .any(|t| t == SHELL_TOOL || t == REPLY_OBLIGATION_TOOL) => + { + None + } + Some(_) => Some(REPLY_OBLIGATION_TOOL), + } +} /// **What one trigger's forced ending is** (nxf 6j6v.553s (a)) — the thread that is waiting, and /// whether this particular step may answer with a VERDICT. @@ -1445,8 +1582,8 @@ mod tests { "the obligation is discharged by running a shell command: {obligation}" ); assert_eq!( - REPLY_OBLIGATION_TOOL, "Bash", - "…and the tool the engine grants for it is the one that runs a shell command" + REPLY_OBLIGATION_TOOL, "Bash(nxc reply:*)", + "…and the tool the engine grants for it runs that shell command and no other" ); } diff --git a/crates/chat/src/schema.rs b/crates/chat/src/schema.rs index bcd692b..7559061 100644 --- a/crates/chat/src/schema.rs +++ b/crates/chat/src/schema.rs @@ -723,6 +723,9 @@ pub fn try_apply_chat_views(conn: &Connection) -> rusqlite::Result { "DROP TABLE IF EXISTS read_cursors; DROP INDEX IF EXISTS messages_channel;", )?; + // The border record (nxf 6j6v.4gp2): device-local, like `session_map` — it names where the + // other half of a conversation lives on THIS machine. See `crate::border`. + conn.execute_batch(crate::border::BORDER_DDL)?; Ok(upgraded) } diff --git a/crates/chat/src/store.rs b/crates/chat/src/store.rs index 7b7afd9..77a493d 100644 --- a/crates/chat/src/store.rs +++ b/crates/chat/src/store.rs @@ -704,6 +704,23 @@ pub struct ChatStore { inner: Substrate, } +/// **A thread's parent, unless the thread came in across the border** (nxf 6j6v.4gp2) — the +/// `threads.parent` column as the tree walks read it. +/// +/// A border commission is opened in the CALLER's workspace under the thread the caller stands in, +/// and its root op carries that parent. Copied into the receiver's log, the parent names a thread +/// the receiver does not hold and never will — only the border thread crosses — so a walk would end +/// at a root that does not exist, and the receiver's whole operation would hang under a phantom. +/// For the receiver the border thread IS the root of what it does about the commission; this is +/// where that is said, once, for the two reads every walk to a root goes through — +/// [`thread_parent`](ChatStore::thread_parent) and [`thread_edges`](ChatStore::thread_edges). +/// Three reads still see the raw column, and none of them walks up: the "parent moved on" check +/// (it asks about messages in a parent this workspace does not hold, and finds none), the children +/// of a given thread (nobody asks for the children of a thread that is not here), and the reducer +/// that stores the edge as the signed op carries it. +const PARENT_WITHIN_THE_BORDER: &str = "CASE WHEN EXISTS(SELECT 1 FROM border_threads b \ + WHERE b.thread_id = threads.thread_id AND b.direction = 'in') THEN NULL ELSE parent END"; + impl ChatStore { pub fn open_in_memory(site: i64) -> ChatStore { let mut inner = Substrate::open_in_memory(site); @@ -750,6 +767,18 @@ impl ChatStore { pub fn apply(&mut self, ops: &[Op]) -> Vec { self.inner.apply(ops) } + /// The ops with these ids, in log order (nxf 6j6v.4gp2 — what a border thread copies). + pub fn ops_with_ids(&self, ids: &[String]) -> Vec { + self.inner.ops_with_ids(ids) + } + /// Whether this log holds the op `op_id`. + pub fn holds_op(&self, op_id: &str) -> bool { + self.inner.holds_op(op_id) + } + /// Whether `key_id` is on this replica's trust list (its own key always is). + pub fn is_trusted(&self, key_id: &str) -> bool { + self.inner.trusted_keys().iter().any(|k| k.key_id == key_id) + } pub fn refold(&mut self) { self.inner.refold(); } @@ -1391,8 +1420,10 @@ impl ChatStore { &self, ) -> crate::error::Result, Option)>> { let conn = self.inner.connection(); - let mut st = - conn.prepare("SELECT thread_id, parent, channel_id FROM threads ORDER BY thread_id")?; + let mut st = conn.prepare(&format!( + "SELECT thread_id, {}, channel_id FROM threads ORDER BY thread_id", + PARENT_WITHIN_THE_BORDER + ))?; let rows = st .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? .collect::>>()?; @@ -1503,7 +1534,9 @@ impl ChatStore { /// `threads_tree` covering index. A db error → `io`. pub fn thread_parent(&self, thread_id: &str) -> crate::error::Result> { let conn = self.inner.connection(); - let mut st = conn.prepare("SELECT parent FROM threads WHERE thread_id = ?1")?; + let mut st = conn.prepare(&format!( + "SELECT {PARENT_WITHIN_THE_BORDER} FROM threads WHERE thread_id = ?1" + ))?; let mut rows = st.query([thread_id])?; match rows.next()? { Some(row) => Ok(row.get(0)?), @@ -2758,6 +2791,10 @@ pub(crate) mod tests { "withdrawn_holders", "withdrawn_sessions", "session_interruption", + // The border record (nxf 6j6v.4gp2): where the other half of a conversation lives on + // THIS machine, and what of it was handed to this side's party. + "border_threads", + "border_served", ]; let store = ChatStore::open_in_memory(1); let tables: Vec = store diff --git a/crates/chat/src/surface.rs b/crates/chat/src/surface.rs index a1f8e39..f15900f 100644 --- a/crates/chat/src/surface.rs +++ b/crates/chat/src/surface.rs @@ -267,6 +267,11 @@ pub struct SendToReceipt { /// receipt of a chat that runs where it was written is byte-identical to before. #[serde(skip_serializing_if = "Option::is_none")] pub handed_to: Option, + /// **The commission went to a persona of ANOTHER workspace** (nxf 6j6v.4gp2) — which one, and + /// where the border thread stands now: `working` once that workspace took it in, `rejected` + /// with the reason when it refused. Absent for every send inside this workspace. + #[serde(skip_serializing_if = "Option::is_none")] + pub border: Option, /// Best-effort steps that did NOT happen even though the thread and message above ARE durably /// posted (nxf 6j6v.hpv8). Relayed verbatim from [`orchestration::TriggerReceipt::warnings`] on /// the persona path — see that field's own doc for the failure this closes (a summon that fails @@ -332,7 +337,7 @@ pub struct SendToReceipt { /// the coordinator can resume, and a second answer here could disagree with the refusal a line /// away. A process that simply never registers is "a human" to this check, exactly as it is there — /// and lands on the advice that works for anybody, which is the benign direction. -fn caller_identity(ctx: &Ctx, store: &ChatStore) -> Result { +pub(crate) fn caller_identity(ctx: &Ctx, store: &ChatStore) -> Result { crate::persona::resolve_identity(store, ctx.defs.roles(), None, ctx.session) } @@ -373,7 +378,10 @@ fn refusal(decl: &crate::role::RoleDecl, caller: &crate::persona::Identity, ctx: // A whitelist refused THIS caller. Naming the caller's own class is what makes the refusal // actionable rather than merely true: "a persona may not" is the thing to change a // declaration over, and it is invisible from the outside. - Addressable::Only { personas, humans } => { + Addressable::Only { personas, humans } + | Addressable::OnlyWithExternal { + personas, humans, .. + } => { let who = match (personas.as_slice(), humans) { ([], true) => "only the human at the terminal may".to_string(), // An empty whitelist admits nobody — the same answer as `none`, reached through @@ -459,6 +467,54 @@ pub fn send_to(ctx: &Ctx, store: &mut ChatStore, req: SendToRequest) -> Result//`. + // Its own workspace's name is just a longer way to write the bare handle, and falls through. + if let Some(address) = crate::border::parse_address(req.to) { + let own_name = ctx.peers.and_then(|p| p.here(ctx.db_path).ok().flatten()); + if own_name.as_deref() == Some(address.workspace) { + return send_to( + ctx, + store, + SendToRequest { + to: address.persona, + body: req.body, + refs: SendToRefs::Declared(refs), + machine: req.machine, + }, + ) + .map(|mut r| { + r.refs_warning = refs_warning.or(r.refs_warning); + r + }); + } + if req.machine.is_some() { + return Err(NxfError::validation(format!( + "{} is a persona of another workspace, which runs where that workspace is — \ + --machine names the machine of a chat in this one", + req.to + ))); + } + let c = crate::border::commission(ctx, store, address, req.body, refs)?; + return Ok(SendToReceipt { + thread_id: c.thread_id.clone(), + message_id: c.message_id, + to: req.to.to_string(), + target: TargetKind::Persona, + channel: c.channel, + session: None, + queued_behind: None, + queue_position: None, + expects: vec![c.expects], + deadline: None, + spawned: false, + handed_to: None, + border: Some(c.border), + warnings: Vec::new(), + refs_warning, + await_: Await::for_caller(persona, &c.thread_id, None), + }); + } + // (1) A declared channel: hand the whole thing to the existing verb. if ctx.defs.declared_channel(req.to)?.is_some() { // A channel runs where it is started (nxf 6j6v.1c6k, spec §2.1): its supervisor's state is @@ -493,6 +549,7 @@ pub fn send_to(ctx: &Ctx, store: &mut ChatStore, req: SendToRequest) -> Result Result "channel", Coordinator::Return => "return", Coordinator::Released => "released", + Coordinator::Border => "border", } } } @@ -1965,11 +1971,25 @@ impl Worker for SidecarWorker { /// Takes an explicit ambient-lookup closure (dependency injection) so the decision is pure and /// independently unit-testable without mutating process-global env. The closure receives an env /// var key and returns `Some(value)` if present, `None` if unset. +/// +/// **`NXS_SERVICE_INSTANCE` travels too** (nxf 6j6v.4gp2). It says which background-service home a +/// development build belongs to (`~/.nexusflow-`), and a session spawned without it +/// resolved the PRODUCTION home from inside a dev build: its `nxc` read another registry than the +/// one that spawned it — found when a border commission from a spawned persona could not see the +/// workspace its own coordinator had just found. An installed binary ignores the variable, so +/// forwarding it changes nothing outside a build. fn forwarded_real_env(ambient: impl Fn(&str) -> Option) -> Vec<(String, String)> { - ["PATH", "HOME", "USER", "NXC_SIDECAR", "NXC_WORKER"] - .iter() - .filter_map(|k| ambient(k).map(|v| (k.to_string(), v))) - .collect() + [ + "PATH", + "HOME", + "USER", + "NXC_SIDECAR", + "NXC_WORKER", + nxs_service::instance::INSTANCE_ENV, + ] + .iter() + .filter_map(|k| ambient(k).map(|v| (k.to_string(), v))) + .collect() } /// **What a declared HURDLE's shell may see** (nxf 6j6v.n92p; PR #391 review, Integrity #1) — the @@ -3214,13 +3234,18 @@ mod tests { "USER" => "ckoch".to_string(), "NXC_SIDECAR" => "/path/to/main.mjs".to_string(), "NXC_WORKER" => "sidecar".to_string(), + "NXS_SERVICE_INSTANCE" => "nexus-flow-dev".to_string(), _ => return None, }) }; let result = forwarded_real_env(ambient); - assert_eq!(result.len(), 5); + assert_eq!(result.len(), 6); + assert!( + result.iter().any(|(k, _)| k == "NXS_SERVICE_INSTANCE"), + "a session spawned by a dev build must resolve the same service home as its spawner" + ); assert!(result.iter().any(|(k, _)| k == "PATH")); assert!(result.iter().any(|(k, _)| k == "HOME")); assert!(result.iter().any(|(k, _)| k == "NXC_SIDECAR")); diff --git a/crates/chat/src/workspace.rs b/crates/chat/src/workspace.rs index e6d6a99..485df59 100644 --- a/crates/chat/src/workspace.rs +++ b/crates/chat/src/workspace.rs @@ -18,9 +18,14 @@ pub const CHAT_MODULE: &str = "chat"; /// **the workspace's own replica prefix**. /// /// **A per-workspace value, and M1 says so in as many words**: "in M1 it is the local workspace -/// identity"; the org/repo-qualified form is finalized with the federation bridge (nxf 6j6v.kz8p). -/// One string per workspace, the same for every caller in it — and when 6j6v.kz8p gives a workspace -/// a real federated identity, this function is the one place it lands. +/// identity"; the org/repo-qualified form was left to the federation bridge (nxf 6j6v.kz8p). +/// One string per workspace, the same for every caller in it. +/// +/// **The workspace's NAME is not this** (nxf 6j6v.q32p). The `/` an address from +/// another workspace starts with is stored beside the replica (`nxs_foundation::workspace_name`), +/// and the origin stays the prefix: a message copied across the border keeps the origin its author +/// signed, and a status row maps the other side to its name through the border record +/// (`crate::border`), never by rewriting a handle. /// /// **Written once because BOTH seams read it** (nxf 6j6v.07me). `cli.rs`'s `origin()` falls back to /// it when `NXC_ORIGIN` is unset, and [`crate::engine::Engine::origin`] answers with it for every diff --git a/crates/chat/tests/a_conversation_is_named_and_answered.rs b/crates/chat/tests/a_conversation_is_named_and_answered.rs index 1006bc5..51b41b5 100644 --- a/crates/chat/tests/a_conversation_is_named_and_answered.rs +++ b/crates/chat/tests/a_conversation_is_named_and_answered.rs @@ -97,6 +97,7 @@ fn ctx<'a>( project_claude_md: None, module_primes: None, machines: None, + peers: None, } } diff --git a/crates/chat/tests/a_holder_past_its_bound_is_parked.rs b/crates/chat/tests/a_holder_past_its_bound_is_parked.rs index d5f6655..f885904 100644 --- a/crates/chat/tests/a_holder_past_its_bound_is_parked.rs +++ b/crates/chat/tests/a_holder_past_its_bound_is_parked.rs @@ -388,6 +388,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; orchestration::tick(&ctx, &mut store, TickRequest { thread_id: thread }).expect("tick") } diff --git a/crates/chat/tests/a_holder_that_dies_inside_its_bound_is_noticed.rs b/crates/chat/tests/a_holder_that_dies_inside_its_bound_is_noticed.rs index 1e565c8..1b7c2ab 100644 --- a/crates/chat/tests/a_holder_that_dies_inside_its_bound_is_noticed.rs +++ b/crates/chat/tests/a_holder_that_dies_inside_its_bound_is_noticed.rs @@ -383,6 +383,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; orchestration::tick(&ctx, &mut store, TickRequest { thread_id: thread }).expect("tick") } diff --git a/crates/chat/tests/a_persona_commissions_across_the_border.rs b/crates/chat/tests/a_persona_commissions_across_the_border.rs new file mode 100644 index 0000000..4be38ea --- /dev/null +++ b/crates/chat/tests/a_persona_commissions_across_the_border.rs @@ -0,0 +1,1013 @@ +//! **A persona commissions a persona of ANOTHER workspace on the same machine** (nxf 6j6v.70dy: +//! 6j6v.q32p, 6j6v.t5xb, 6j6v.4gp2, 6j6v.szc5) — at the engine seam, between two real workspaces. +//! +//! Each workspace is a real directory with its own `.nxs/` log, its own replica key and its own +//! declarations; the only thing they share is a recording worker (so the test can see who was +//! started where) and a [`Peers`] that knows both by name and runs the other side's handover +//! in-process — what the `nxs` binary does with a child process in the other directory. +//! +//! The acceptance points of the slice's specification (section 9) this file holds: +//! +//! - 1 — the scenario: the PM of `test/alpha` asks the PM of `test/beta`, the answer wakes it; +//! - 2 — the coder is refused, and the refusal names "role not admitted"; +//! - 3 — an untrusted workspace is refused, and a foreign `pm` does not pass `personas: [pm]`; +//! - 4 — a question from the foreign PM wakes the commissioner, nobody else; +//! - 5 — a back-and-forth ends at the depth cap; +//! - 6 — only the border thread crosses; +//! - 8 — without `external` and without peers, nothing changes. + +mod common; + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use nexus_chat::border::{BorderState, PeerWorkspace, Peers}; +use nexus_chat::definitions::Definitions; +use nexus_chat::engine::{Engine, EngineConfig}; +use nexus_chat::orchestration::Caller; +use nexus_chat::role::RoleDecl; +use nexus_chat::surface::{ReplyThreadRequest, SendToReceipt, SendToRefs, SendToRequest}; +use nexus_chat::timer::TimerConfig; +use nexus_chat::worker::{ + Coordinator, TriggerOutcome, TriggerRequest, TriggerResult, Worker, WorkerConfig, +}; +use nexus_chat::workspace::{chat_config, setup, ChatWorkspaceExt}; +use tempfile::TempDir; + +const NOW: &str = "2026-10-03T10:00:00Z"; + +/// Every trigger, with the workspace it was handed to — told apart by the database the spawned +/// session would write to (`NXC_DB` in the request's env). +#[derive(Default)] +struct Recorder { + seen: Mutex>, + stopped: Mutex>, + /// Sessions that are mid-turn: a resume of one is refused as already running, which is what + /// makes an answer for it HELD. + busy: Mutex>, +} + +impl Recorder { + fn all(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + /// The triggers handed to `workspace` for persona `handle`, oldest first. + fn of(&self, workspace: &Side, handle: &str) -> Vec { + self.all() + .into_iter() + .filter(|r| r.role.handle == handle && workspace.owns(r)) + .collect() + } +} + +impl Worker for Recorder { + fn trigger(&self, req: TriggerRequest) -> TriggerResult { + if self.busy.lock().unwrap().contains(&req.internal_session) { + return Err(nexus_chat::worker::TriggerError::AlreadyRunning { + session: req.internal_session, + pid: 4242, + }); + } + self.seen.lock().unwrap().push(req); + Ok(TriggerOutcome::Accepted) + } + /// Every session here has done its turn and returned — the recorder runs nothing — and it says + /// so, which is what lets a withdrawal go ahead. + fn answers_liveness(&self) -> bool { + true + } + fn stop_session( + &self, + internal_session: &str, + ) -> Result { + self.stopped + .lock() + .unwrap() + .push(internal_session.to_string()); + Ok(nexus_chat::worker::SessionStop::Requested) + } +} + +/// What the two workspaces share: the worker every spawn of either reaches. +struct TwoWorkspaces { + worker: Arc, + failing_kicks: Arc, +} + +/// The test's [`Peers`]: both workspaces by name, and the other side's handover run in-process +/// through its own [`Engine`]. +#[derive(Debug, Clone)] +struct InProcessPeers { + by_name: Vec<(String, PathBuf)>, + worker: Arc, + /// How many of the next peer handovers fail before running — a peer database that was busy, + /// a binary that was gone. + failing_kicks: Arc, +} + +impl std::fmt::Debug for Recorder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Recorder") + } +} + +impl Peers for InProcessPeers { + fn here(&self, db_path: &str) -> Result, String> { + let db = std::fs::canonicalize(db_path).map_err(|e| e.to_string())?; + Ok(self + .by_name + .iter() + .find(|(_, root)| { + std::fs::canonicalize(root.join(".nxs/db.sqlite")) + .ok() + .as_ref() + == Some(&db) + }) + .map(|(name, _)| name.clone())) + } + + fn find(&self, name: &str) -> Result, String> { + Ok(self + .by_name + .iter() + .filter(|(n, _)| n == name) + .map(|(n, root)| PeerWorkspace { + name: n.clone(), + root: root.clone(), + }) + .collect()) + } + + fn hand_over_in(&self, peer: &PeerWorkspace) -> Result<(), String> { + use std::sync::atomic::Ordering; + let left = self.failing_kicks.load(Ordering::SeqCst); + if left > 0 + && self + .failing_kicks + .compare_exchange(left, left - 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + return Err(format!("{} is busy", peer.name)); + } + let engine = open_engine(&peer.root, self.worker.clone(), Arc::new(self.clone())); + engine + .handover(machine_caller()) + .map(|_| ()) + .map_err(|e| e.msg) + } +} + +fn open_engine(root: &Path, worker: Arc, peers: Arc) -> Engine { + Engine::open_with( + None, + root, + EngineConfig { + worker: WorkerConfig::Custom(worker), + timer: TimerConfig::Dry, + peers: Some(peers), + ..EngineConfig::default() + }, + ) + .expect("open engine") +} + +/// The coordinator running the handover, which acts in nobody's name of its own. +fn machine_caller() -> Caller<'static> { + Caller { + session: None, + actor: Some("handover"), + now: Some(NOW), + } +} + +/// One side, opened. +struct Side { + root: PathBuf, + engine: Engine, +} + +impl Side { + fn owns(&self, req: &TriggerRequest) -> bool { + let db = std::fs::canonicalize(self.root.join(".nxs/db.sqlite")).unwrap(); + req.env + .iter() + .find(|(k, _)| k == "NXC_DB") + .and_then(|(_, v)| std::fs::canonicalize(v).ok()) + .is_some_and(|v| v == db) + } + + fn as_person(&self, to: &str, body: &str) -> SendToReceipt { + self.engine + .send_to( + Caller { + session: None, + actor: Some("carsten"), + now: Some(NOW), + }, + send(to, body), + ) + .unwrap_or_else(|e| panic!("send to {to}: {}", e.msg)) + } + + fn as_session(&self, session: &str, to: &str, body: &str) -> Result { + self.engine + .send_to( + Caller { + session: Some(session), + actor: None, + now: Some(NOW), + }, + send(to, body), + ) + .map_err(|e| e.msg) + } + + fn reply(&self, session: &str, thread: &str, body: &str, escalate: bool) { + self.engine + .reply_thread( + Caller { + session: Some(session), + actor: None, + now: Some(NOW), + }, + ReplyThreadRequest { + machine: None, + thread, + body, + escalate, + needs_rework: false, + accept: false, + }, + ) + .unwrap_or_else(|e| panic!("reply on {thread}: {}", e.msg)); + } + + fn border_rows(&self) -> Vec { + let ws = nxs_foundation::workspace::discover(&self.root).unwrap(); + let store = ws.open_chat_store().unwrap(); + nexus_chat::border::rows(&store).unwrap() + } + + fn thread_ids(&self) -> Vec { + let ws = nxs_foundation::workspace::discover(&self.root).unwrap(); + let store = ws.open_chat_store().unwrap(); + store + .thread_edges() + .unwrap() + .into_iter() + .map(|(id, _, _)| id) + .collect() + } +} + +fn send<'a>(to: &'a str, body: &'a str) -> SendToRequest<'a> { + SendToRequest { + to, + body, + refs: SendToRefs::ExplicitlyNone, + machine: None, + } +} + +fn role(yaml: &str) -> RoleDecl { + serde_yaml::from_str(yaml).expect("test role parses") +} + +/// A workspace at `root` named `name`, declaring `roles`. +fn workspace(root: &Path, name: &str, roles: Vec) { + std::fs::create_dir_all(root).unwrap(); + setup(root, &chat_config()).expect("seed chat workspace"); + nxs_foundation::workspace_name::set_name(&root.join(".nxs"), name).expect("name it"); + let defs = Definitions::new(roles, vec![]).expect("catalogue"); + common::write_declarations(root, &defs); +} + +fn key_of(root: &Path) -> String { + let ws = nxs_foundation::workspace::discover(root).unwrap(); + ws.open_chat_store().unwrap().key_id().to_string() +} + +fn trust(root: &Path, other: &Path, name: &str) { + let ws = nxs_foundation::workspace::discover(root).unwrap(); + let mut store = ws.open_chat_store().unwrap(); + store.trust_key(&key_of(other), name, NOW).unwrap(); +} + +const ALPHA_PM: &str = "handle: pm\nsystem_prompt: You are the PM of test/alpha.\n"; +const ALPHA_CODER: &str = "handle: coder\nsystem_prompt: You are a coder.\n"; +const BETA_PM: &str = "handle: pm\nsystem_prompt: You are the PM of test/beta.\naddressable:\n humans: true\n external:\n - \"*/pm\"\n"; + +/// Both workspaces, trusting each other unless told otherwise. +fn two( + tmp: &TempDir, + alpha_roles: &[&str], + beta_roles: &[&str], + alpha_trusts_beta: bool, + beta_trusts_alpha: bool, +) -> (Side, Side, TwoWorkspaces) { + let alpha = tmp.path().join("alpha"); + let beta = tmp.path().join("beta"); + workspace( + &alpha, + "test/alpha", + alpha_roles.iter().map(|y| role(y)).collect(), + ); + workspace( + &beta, + "test/beta", + beta_roles.iter().map(|y| role(y)).collect(), + ); + if alpha_trusts_beta { + trust(&alpha, &beta, "test/beta"); + } + if beta_trusts_alpha { + trust(&beta, &alpha, "test/alpha"); + } + let worker = Arc::new(Recorder::default()); + let failing_kicks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let peers = Arc::new(InProcessPeers { + by_name: vec![ + ("test/alpha".to_string(), alpha.clone()), + ("test/beta".to_string(), beta.clone()), + ], + worker: worker.clone(), + failing_kicks: failing_kicks.clone(), + }); + let a = Side { + root: alpha.clone(), + engine: open_engine(&alpha, worker.clone(), peers.clone()), + }; + let b = Side { + root: beta.clone(), + engine: open_engine(&beta, worker.clone(), peers), + }; + ( + a, + b, + TwoWorkspaces { + worker, + failing_kicks, + }, + ) +} + +/// The person commissions alpha's `handle`, and the session started for it is returned. +fn alpha_session(a: &Side, w: &TwoWorkspaces, handle: &str) -> (String, String) { + let receipt = a.as_person(handle, "find it out"); + let session = w + .worker + .of(a, handle) + .last() + .expect("the persona was started") + .internal_session + .clone(); + (session, receipt.thread_id) +} + +// ---- 1: the scenario ------------------------------------------------------------------------- + +#[test] +fn the_pm_of_one_workspace_asks_the_pm_of_another_and_is_woken_with_the_answer() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, owners_thread) = alpha_session(&a, &w, "pm"); + + let receipt = a + .as_session(&alpha_pm, "test/beta/pm", "When does the access rule ship?") + .expect("the commission is recorded and handed over"); + let border = receipt.border.clone().expect("a border receipt"); + assert_eq!(border.peer, "test/beta"); + assert_eq!(border.state, BorderState::Working, "{receipt:?}"); + + // The receiver's coordinator admitted it and started ITS pm, owing the border thread a reply. + let started = w.worker.of(&b, "pm"); + let [beta_pm] = started.as_slice() else { + panic!("exactly one pm started in beta: {started:?}") + }; + assert_eq!(beta_pm.coordinator, Coordinator::Border); + assert_eq!( + beta_pm.reply_thread.as_deref(), + Some(receipt.thread_id.as_str()) + ); + assert!( + beta_pm.message.contains("From test/alpha/pm"), + "the persona is told who asks: {}", + beta_pm.message + ); + + b.reply( + &beta_pm.internal_session, + &receipt.thread_id, + "With version 0.300.", + false, + ); + + // The answer crossed back and woke the commissioner — the same session, with the answer. + let woken = w.worker.of(&a, "pm"); + let resumed = woken.last().expect("alpha's pm was woken"); + assert_eq!(resumed.internal_session, alpha_pm); + assert_eq!(resumed.coordinator, Coordinator::Return); + assert!(resumed.message.contains("0.300"), "{}", resumed.message); + // …and it can pass it on: the wake carries the obligation it still has to its owner, so a pm + // declaring no tools is granted the means to answer. + assert_eq!( + resumed.reply_thread.as_deref(), + Some(owners_thread.as_str()) + ); + assert!(!resumed.role.granted_tools.is_empty(), "{:?}", resumed.role); + + let out = a.border_rows(); + assert_eq!(out[0].state, BorderState::Completed, "{out:?}"); + let inbound = b.border_rows(); + assert_eq!(inbound[0].state, BorderState::Completed, "{inbound:?}"); + assert_eq!(inbound[0].peer, "test/alpha"); +} + +// ---- 2 and 3: who is refused, and why ---------------------------------------------------------- + +#[test] +fn the_coder_is_refused_with_role_not_admitted_and_nothing_starts_there() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM, ALPHA_CODER], &[BETA_PM], true, true); + let (coder, _) = alpha_session(&a, &w, "coder"); + + let receipt = a + .as_session(&coder, "test/beta/pm", "When does the access rule ship?") + .expect("the commission is recorded; the refusal comes back as its answer"); + let border = receipt.border.expect("a border receipt"); + assert_eq!(border.state, BorderState::Rejected); + assert_eq!(border.reason.as_deref(), Some("role not admitted")); + assert!( + w.worker.of(&b, "pm").is_empty(), + "nothing was started in beta" + ); + + let woken = w.worker.of(&a, "coder"); + assert!( + woken.last().unwrap().message.contains("role not admitted"), + "the coder is woken with the reason: {}", + woken.last().unwrap().message + ); +} + +#[test] +fn a_workspace_the_receiver_does_not_trust_is_refused_as_not_trusted() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, false); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + + let receipt = a + .as_session(&alpha_pm, "test/beta/pm", "When?") + .expect("recorded"); + let border = receipt.border.expect("a border receipt"); + assert_eq!(border.state, BorderState::Rejected); + assert_eq!(border.reason.as_deref(), Some("not trusted")); + assert!(w.worker.of(&b, "pm").is_empty()); +} + +#[test] +fn the_caller_refuses_up_front_when_it_does_not_trust_the_receiver_itself() { + // The answer would arrive signed by a key the caller's workspace does not believe, and an + // unvouched message wakes nobody — so the caller is told before anything is written. + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], false, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + + let err = a + .as_session(&alpha_pm, "test/beta/pm", "When?") + .expect_err("refused"); + assert!(err.contains("not trusted"), "{err}"); + assert!( + err.contains("nxs sync trust add --workspace test/beta"), + "{err}" + ); + assert!(a.border_rows().is_empty() && b.border_rows().is_empty()); +} + +#[test] +fn a_foreign_pm_does_not_pass_a_bare_personas_entry() { + // `personas: [pm]` means THIS workspace's pm. A handle reduced to its last segment would have let + // `test/alpha/pm` through (nxf 6j6v.q32p). + let tmp = TempDir::new().unwrap(); + let only_own_pm = "handle: lead\nsystem_prompt: You lead.\naddressable:\n personas: [pm]\n"; + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM, only_own_pm], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + + let receipt = a + .as_session(&alpha_pm, "test/beta/lead", "Hello") + .expect("recorded"); + let border = receipt.border.expect("a border receipt"); + assert_eq!(border.state, BorderState::Rejected); + assert_eq!(border.reason.as_deref(), Some("role not admitted")); + assert!(w.worker.of(&b, "lead").is_empty()); +} + +#[test] +fn a_person_cannot_commission_a_persona_of_another_workspace() { + let tmp = TempDir::new().unwrap(); + let (a, _b, _w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let err = a + .engine + .send_to( + Caller { + session: None, + actor: Some("carsten"), + now: Some(NOW), + }, + send("test/beta/pm", "Hello"), + ) + .expect_err("a person goes to that workspace"); + assert!( + err.msg.contains("only a persona can commission it"), + "{}", + err.msg + ); +} + +#[test] +fn an_address_nobody_on_this_machine_carries_is_not_on_this_machine() { + let tmp = TempDir::new().unwrap(); + let (a, _b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let err = a + .as_session(&alpha_pm, "test/gamma/pm", "Hello") + .expect_err("refused"); + assert!(err.contains("not on this machine"), "{err}"); +} + +// ---- 4: a question goes to the commissioner ---------------------------------------------------- + +#[test] +fn a_question_from_the_foreign_pm_wakes_the_commissioner_and_its_answer_goes_back() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "Plan it.").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + + b.reply( + &beta_pm, + &receipt.thread_id, + "Which release do you mean?", + true, + ); + + let woken = w.worker.of(&a, "pm"); + let wake = woken.last().unwrap(); + assert_eq!( + wake.internal_session, alpha_pm, + "the commissioner, nobody else" + ); + assert!(wake.message.contains("Which release"), "{}", wake.message); + assert_eq!(a.border_rows()[0].state, BorderState::InputRequired); + assert_eq!(b.border_rows()[0].state, BorderState::InputRequired); + // Nothing in beta was woken for it: the question is not beta's owner's to answer — and beta's + // view does not tell its owner it is waiting on them either. + assert_eq!(w.worker.of(&b, "pm").len(), 1); + let in_beta = status_row(&b, &receipt.thread_id); + assert!(in_beta.escalated && !in_beta.awaiting_human, "{in_beta:?}"); + let beta_ops = b + .engine + .status(NOW, nexus_chat::facade::StatusScope::Workspace) + .unwrap() + .operations; + let op = beta_ops + .iter() + .find(|op| op.root == receipt.thread_id) + .expect("the operation is live while the commissioner has the question"); + assert!(!op.needs_decision, "{op:?}"); + + // The commissioner answers on the border thread, and the foreign pm is resumed with it. + a.reply(&alpha_pm, &receipt.thread_id, "The next one, 0.300.", false); + let beta_triggers = w.worker.of(&b, "pm"); + let resumed = beta_triggers.last().unwrap(); + assert_eq!(beta_triggers.len(), 2, "{beta_triggers:?}"); + assert_eq!(resumed.internal_session, beta_pm); + assert!(resumed.message.contains("0.300"), "{}", resumed.message); + assert_eq!(a.border_rows()[0].state, BorderState::Working); +} + +// ---- 5: the depth cap holds across both workspaces --------------------------------------------- + +#[test] +fn a_back_and_forth_ends_at_the_depth_cap_instead_of_circling() { + let tmp = TempDir::new().unwrap(); + let open_pm = |ws: &str| { + format!( + "handle: pm\nsystem_prompt: You are the PM of {ws}.\naddressable:\n humans: true\n external:\n - \"*/pm\"\n" + ) + }; + let (alpha_yaml, beta_yaml) = (open_pm("test/alpha"), open_pm("test/beta")); + let (a, b, w) = two(&tmp, &[&alpha_yaml], &[&beta_yaml], true, true); + let (mut session, _) = alpha_session(&a, &w, "pm"); + let sides = [(&a, "test/beta/pm"), (&b, "test/alpha/pm")]; + let mut ended = None; + for hop in 0..64 { + let (from, to) = sides[hop % 2]; + match from.as_session(&session, to, "And you?") { + Ok(r) + if r.border + .as_ref() + .is_some_and(|b| b.state == BorderState::Rejected) => + { + ended = Some((hop, r.border.unwrap().reason.unwrap())); + break; + } + Ok(_) => {} + Err(e) => { + ended = Some((hop, e)); + break; + } + } + let other = if hop % 2 == 0 { &b } else { &a }; + session = w + .worker + .of(other, "pm") + .last() + .unwrap() + .internal_session + .clone(); + } + let (hop, why) = ended.expect("the chain ended"); + assert!( + why.contains("depth"), + "it ended at the depth cap, after {hop} crossings: {why}" + ); + assert!( + hop >= 30, + "the cap is the whole chain's, not a per-side one: {hop}" + ); +} + +// ---- 6: only the border thread crosses --------------------------------------------------------- + +#[test] +fn only_the_border_thread_crosses_and_what_the_receiver_commissions_behind_it_stays_there() { + let tmp = TempDir::new().unwrap(); + let helper = "handle: helper\nsystem_prompt: You help.\n"; + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM, helper], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a + .as_session(&alpha_pm, "test/beta/pm", "Find out.") + .unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + + let behind = b + .as_session(&beta_pm, "helper", "Look it up for me.") + .expect("the receiver commissions in its own workspace"); + let helper_session = w.worker.of(&b, "helper")[0].internal_session.clone(); + b.reply(&helper_session, &behind.thread_id, "It is 0.300.", false); + b.reply(&beta_pm, &receipt.thread_id, "Done: 0.300.", false); + + let alpha_threads = a.thread_ids(); + assert!(alpha_threads.contains(&receipt.thread_id)); + assert!( + !alpha_threads.contains(&behind.thread_id), + "the thread behind the border is not in the caller's log: {alpha_threads:?}" + ); + let beta_threads = b.thread_ids(); + let beta_from_alpha: Vec<&String> = beta_threads + .iter() + .filter(|t| a.thread_ids().contains(t)) + .collect(); + assert_eq!( + beta_from_alpha, + vec![&receipt.thread_id], + "of the caller's threads only the border thread is in the receiver's log" + ); +} + +#[test] +fn a_second_handover_moves_and_wakes_nothing() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + b.reply(&beta_pm, &receipt.thread_id, "0.300", false); + let before = w.worker.all().len(); + + for side in [&a, &b, &a] { + let report = side.engine.handover(machine_caller()).unwrap(); + assert!(report.exchanged.is_empty(), "{report:?}"); + assert!( + report.woke.is_empty() && report.admitted.is_empty(), + "{report:?}" + ); + } + assert_eq!(w.worker.all().len(), before, "no echo, no second wake"); +} + +// ---- 8: a workspace that opens nothing behaves as before --------------------------------------- + +#[test] +fn without_peers_an_address_is_refused_and_a_bare_handle_is_untouched() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("solo"); + workspace(&root, "test/solo", vec![role(ALPHA_PM)]); + let worker = Arc::new(Recorder::default()); + let engine = Engine::open_with( + None, + &root, + EngineConfig { + worker: WorkerConfig::Custom(worker.clone()), + timer: TimerConfig::Dry, + ..EngineConfig::default() + }, + ) + .unwrap(); + let person = || Caller { + session: None, + actor: Some("carsten"), + now: Some(NOW), + }; + let err = engine + .send_to(person(), send("test/beta/pm", "Hello")) + .expect_err("no peers"); + assert!( + err.msg.contains("reaches no other workspace"), + "{}", + err.msg + ); + let receipt = engine.send_to(person(), send("pm", "Hello")).unwrap(); + assert!(receipt.border.is_none()); + assert_eq!(worker.all().len(), 1); +} + +// ---- szc5: the state and the other side, in both workspaces ------------------------------------ + +/// The status row of `thread` in `side`'s own report. +fn status_row(side: &Side, thread: &str) -> nexus_chat::facade::StatusThread { + let report = side + .engine + .status(NOW, nexus_chat::facade::StatusScope::All(None)) + .expect("status"); + report + .operations + .into_iter() + .flat_map(|op| op.threads) + .find(|t| t.thread_id == thread) + .unwrap_or_else(|| panic!("{thread} is in the report")) +} + +#[test] +fn the_border_thread_shows_its_state_and_the_other_side_in_both_workspaces() { + use nexus_chat::border::Direction; + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + + let out = status_row(&a, &receipt.thread_id) + .border + .expect("a border row"); + let inbound = status_row(&b, &receipt.thread_id) + .border + .expect("a border row"); + assert_eq!( + (out.peer.as_str(), out.direction, out.state), + ("test/beta", Direction::Outbound, BorderState::Working) + ); + assert_eq!( + (inbound.peer.as_str(), inbound.direction, inbound.state), + ("test/alpha", Direction::Inbound, BorderState::Working) + ); + // In the receiver the border thread is the ROOT of what it does about the commission: the + // caller's thread above it is not there, and the operation does not hang under a phantom. + assert_eq!(status_row(&b, &receipt.thread_id).parent, None); + + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + b.reply(&beta_pm, &receipt.thread_id, "0.300", false); + assert_eq!( + status_row(&a, &receipt.thread_id).border.unwrap().state, + BorderState::Completed + ); + assert_eq!( + status_row(&b, &receipt.thread_id).border.unwrap().state, + BorderState::Completed + ); + // A thread that stays in its workspace carries no border. + let owners = a.thread_ids(); + let local = owners.iter().find(|t| **t != receipt.thread_id).unwrap(); + assert!(status_row(&a, local).border.is_none()); +} + +// ---- the deadline, and a withdrawal, end it on both sides -------------------------------------- + +#[test] +fn two_hours_without_a_sign_of_life_cancel_it_and_wake_the_commissioner() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + let at = |now: &'static str| Caller { + session: None, + actor: Some("handover"), + now: Some(now), + }; + + // Quiet for an hour is not quiet for two: a persona reading its board is working. + a.engine.handover(at("2026-10-03T11:00:00Z")).unwrap(); + assert_eq!(a.border_rows()[0].state, BorderState::Working); + + a.engine.handover(at("2026-10-03T12:00:01Z")).unwrap(); + let out = a.border_rows(); + assert_eq!(out[0].state, BorderState::Canceled, "{out:?}"); + assert!( + out[0] + .reason + .as_deref() + .unwrap() + .contains("no sign of life"), + "{out:?}" + ); + let wake = w.worker.of(&a, "pm").last().unwrap().clone(); + assert_eq!(wake.internal_session, alpha_pm); + assert!(wake.message.contains("no sign of life"), "{}", wake.message); + + // The receiver was told on the thread and stopped its persona. + assert_eq!(b.border_rows()[0].state, BorderState::Canceled); + assert!(w.worker.stopped.lock().unwrap().contains(&beta_pm)); + // …and the commissioner is no longer waiting on it. + let row = status_row(&a, &receipt.thread_id); + assert!(row.outstanding.is_empty(), "{row:?}"); +} + +#[test] +fn withdrawing_the_operation_takes_the_border_thread_back_in_both_workspaces() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, owners_thread) = alpha_session(&a, &w, "pm"); + a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + + a.engine + .withdraw( + Caller { + session: None, + actor: Some("carsten"), + now: Some(NOW), + }, + &owners_thread, + ) + .expect("the owner takes the operation back"); + + assert_eq!(a.border_rows()[0].state, BorderState::Canceled); + assert_eq!(b.border_rows()[0].state, BorderState::Canceled); + assert!(w.worker.stopped.lock().unwrap().contains(&beta_pm)); +} + +// ---- an answer for a commissioner still in its turn ------------------------------------------ + +#[test] +fn an_answer_held_for_a_commissioner_still_in_its_turn_keeps_it_waiting_rather_than_owing() { + // The race the live run met: the other workspace's pm answers within seconds, while the + // commissioner is still ending its turn. The answer is HELD for it — and until it is handed + // over, the commissioner's own row says it is waiting on its round, so its sidecar does not + // remind it into a substituted reply. + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, owners_thread) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + + w.worker.busy.lock().unwrap().push(alpha_pm.clone()); + b.reply(&beta_pm, &receipt.thread_id, "0.300", false); + + let owners = status_row(&a, &owners_thread); + assert_eq!( + owners.waiting_on_sub_round, + vec![receipt.thread_id.clone()], + "the commissioner is waiting for the answer held for it: {owners:?}" + ); + assert_eq!( + w.worker.of(&a, "pm").len(), + 1, + "nothing was resumed while it was busy" + ); +} + +// ---- review of PR #14: the paths a live run met and the gaps it left ------------------------- + +#[test] +fn the_held_answer_reaches_the_commissioner_once_its_turn_has_ended() { + // The end of the race `an_answer_held_…` starts: the commissioner's turn ends, the held answer + // is handed over — once, with the answer in it. + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + w.worker.busy.lock().unwrap().push(alpha_pm.clone()); + b.reply(&beta_pm, &receipt.thread_id, "0.300", false); + assert_eq!(w.worker.of(&a, "pm").len(), 1, "held, not woken"); + + w.worker.busy.lock().unwrap().clear(); + a.engine + .deliver_held(machine_caller(), &alpha_pm) + .expect("the delivery runs"); + a.engine.handover(machine_caller()).unwrap(); + let woken = w.worker.of(&a, "pm"); + assert_eq!(woken.len(), 2, "woken exactly once: {woken:?}"); + assert_eq!(woken[1].internal_session, alpha_pm); + assert!(woken[1].message.contains("0.300"), "{}", woken[1].message); +} + +#[test] +fn a_sign_of_life_in_the_receivers_transcript_restarts_the_deadline() { + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + let beta_pm = w.worker.of(&b, "pm")[0].internal_session.clone(); + // The persona is reading its board at 11:30 — working, though it has said nothing yet. + b.engine + .transcript_append( + &beta_pm, + &[nexus_chat::transcript::TranscriptEntry { + kind: "tool_use".into(), + at: Some("2026-10-03T11:30:00Z".into()), + tool_use_id: None, + parent_tool_use_id: None, + subagent_type: None, + data: serde_json::json!({"name": "Bash"}), + }], + ) + .unwrap(); + let at = |now: &'static str| Caller { + session: None, + actor: Some("handover"), + now: Some(now), + }; + a.engine.handover(at("2026-10-03T12:00:01Z")).unwrap(); + assert_eq!( + a.border_rows()[0].state, + BorderState::Working, + "two hours from the start, but half an hour from the last sign of life" + ); + a.engine.handover(at("2026-10-03T13:30:01Z")).unwrap(); + assert_eq!(a.border_rows()[0].state, BorderState::Canceled); +} + +#[test] +fn a_commission_whose_first_kick_failed_is_handed_over_by_the_next_handover() { + // The other side's handover did not run (its database was busy). Nothing new is pushed the next + // time — the ops are already there — so the kick has to come from the other side not holding + // the thread yet, or the commission would be stranded. + let tmp = TempDir::new().unwrap(); + let (a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let (alpha_pm, _) = alpha_session(&a, &w, "pm"); + // Two: the send's own kick, and the one its handover tries right after it. + w.failing_kicks + .store(2, std::sync::atomic::Ordering::SeqCst); + let receipt = a.as_session(&alpha_pm, "test/beta/pm", "When?").unwrap(); + assert_eq!(receipt.border.unwrap().state, BorderState::Submitted); + assert!(w.worker.of(&b, "pm").is_empty(), "the kick failed"); + + a.engine.handover(machine_caller()).unwrap(); + assert_eq!( + w.worker.of(&b, "pm").len(), + 1, + "the next handover kicked again" + ); + assert_eq!(a.border_rows()[0].state, BorderState::Working); +} + +#[test] +fn a_border_stamp_on_a_thread_of_this_workspace_makes_no_border_thread() { + // A message in a local thread that merely CARRIES a stamp — written here, by anybody who can + // set refs — is not a commission from outside: no record, no persona, no refusal posted. + let tmp = TempDir::new().unwrap(); + let (_a, b, w) = two(&tmp, &[ALPHA_PM], &[BETA_PM], true, true); + let forged = nexus_chat::model::Refs { + border: Some(nexus_chat::border::BorderRef { + from: "test/alpha".into(), + to: "test/beta/pm".into(), + role: Some("pm".into()), + hop: 0, + refusal: None, + canceled: false, + }), + ..Default::default() + }; + b.engine + .send_to( + Caller { + session: None, + actor: Some("carsten"), + now: Some(NOW), + }, + SendToRequest { + to: "pm", + body: "pretend I came from alpha", + refs: SendToRefs::Declared(forged), + machine: None, + }, + ) + .unwrap(); + let before = w.worker.all().len(); + b.engine.handover(machine_caller()).unwrap(); + assert!(b.border_rows().is_empty(), "{:?}", b.border_rows()); + assert_eq!(w.worker.all().len(), before); +} diff --git a/crates/chat/tests/a_refused_park_follows_the_rule.rs b/crates/chat/tests/a_refused_park_follows_the_rule.rs index 0b11b1e..2f9a7d0 100644 --- a/crates/chat/tests/a_refused_park_follows_the_rule.rs +++ b/crates/chat/tests/a_refused_park_follows_the_rule.rs @@ -369,6 +369,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; orchestration::tick(&ctx, &mut store, TickRequest { thread_id: thread }).expect("tick") } diff --git a/crates/chat/tests/a_way_out_of_a_held_lease.rs b/crates/chat/tests/a_way_out_of_a_held_lease.rs index 9db19cb..0b19b64 100644 --- a/crates/chat/tests/a_way_out_of_a_held_lease.rs +++ b/crates/chat/tests/a_way_out_of_a_held_lease.rs @@ -188,6 +188,7 @@ fn tick(tmp: &TempDir, worker: &LiveSessionWorker, now: &str, thread_id: &str) - project_claude_md: None, module_primes: None, machines: None, + peers: None, }; orchestration::tick(&ctx, &mut store, TickRequest { thread_id }).expect("tick") } diff --git a/crates/chat/tests/an_interrupted_operation_hands_the_working_copy_on.rs b/crates/chat/tests/an_interrupted_operation_hands_the_working_copy_on.rs index a88806c..0855485 100644 --- a/crates/chat/tests/an_interrupted_operation_hands_the_working_copy_on.rs +++ b/crates/chat/tests/an_interrupted_operation_hands_the_working_copy_on.rs @@ -177,6 +177,7 @@ trait Workspaced { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; f(&ctx, &mut store) } diff --git a/crates/chat/tests/an_obligation_comes_with_its_means.rs b/crates/chat/tests/an_obligation_comes_with_its_means.rs index 18ec713..f439400 100644 --- a/crates/chat/tests/an_obligation_comes_with_its_means.rs +++ b/crates/chat/tests/an_obligation_comes_with_its_means.rs @@ -36,7 +36,7 @@ use nexus_chat::channel::ChannelDecl; use nexus_chat::definitions::Definitions; use nexus_chat::engine::{Engine, EngineConfig}; use nexus_chat::orchestration::Caller; -use nexus_chat::role::{RoleDecl, REPLY_OBLIGATION_TOOL}; +use nexus_chat::role::{RoleDecl, REPLY_OBLIGATION_TOOL, SHELL_TOOL}; use nexus_chat::surface::{SendToRefs, SendToRequest}; use nexus_chat::timer::TimerConfig; use nexus_chat::worker::{ @@ -162,13 +162,11 @@ fn a_persona_ordered_to_reply_is_granted_what_running_that_reply_takes() { req.reply_thread.is_some(), "the premise: this trigger DEMANDS a reply — {req:?}" ); - assert!( - req.role - .granted_tools - .iter() - .any(|t| t == REPLY_OBLIGATION_TOOL), - "…so it must also grant the means: {:?}", - req.role.granted_tools + assert_eq!( + req.role.granted_tools, + vec![SHELL_TOOL.to_string()], + "…so it must also grant the means — for a role that declared no `tools:`, the bare shell \ + it always had (nxf 6j6v.ewbj scopes only a DECLARED list)" ); assert_eq!( req.role.tools, None, @@ -192,14 +190,7 @@ fn a_channel_member_ordered_to_reply_is_granted_it_too() { let req = worker.for_role("checker"); assert!(req.reply_thread.is_some(), "{req:?}"); - assert!( - req.role - .granted_tools - .iter() - .any(|t| t == REPLY_OBLIGATION_TOOL), - "{:?}", - req.role.granted_tools - ); + assert_eq!(req.role.granted_tools, vec![SHELL_TOOL.to_string()]); } #[test] @@ -219,6 +210,57 @@ fn a_role_that_declared_its_own_tools_keeps_them_and_the_grant_stays_beside_them ); } +#[test] +fn a_persona_declared_with_no_tools_is_granted_the_reply_and_not_the_shell() { + // nxf 6j6v.ewbj, THE hole: `tools: []` is what the guide sells as "this persona cannot act", + // and until then the grant for its obligation was the bare `Bash`, which the sidecar + // auto-approves — any command at all, from a persona declared to have no tools. + let tmp = TempDir::new().unwrap(); + let (engine, worker) = team(&tmp, vec![declared_tools("silent", "[]")], vec![]); + send_to(&engine, "silent"); + + let req = worker.for_role("silent"); + assert!( + req.reply_thread.is_some(), + "the premise: it owes a reply — {req:?}" + ); + assert_eq!(req.role.tools, Some(vec![]), "the declaration stays empty"); + assert_eq!( + req.role.granted_tools, + vec![REPLY_OBLIGATION_TOOL.to_string()], + "the grant is the reply command, scoped — never the bare shell" + ); + assert!( + !req.role.granted_tools.iter().any(|t| t == SHELL_TOOL), + "{:?}", + req.role.granted_tools + ); +} + +#[test] +fn a_persona_that_declared_the_shell_or_the_reply_rule_is_granted_nothing_more() { + // DoD 2 of nxf 6j6v.ewbj: a role that lists `Bash` behaves as before — it already approved + // every command, so nothing is added. And a role that declared the narrow rule itself (the + // coordinator workspace's marketing personas asked for exactly that) keeps it narrow: before + // 6j6v.ewbj the bare grant silently undid that declaration. + for (handle, tools) in [ + ("wide", "[Bash, Read]"), + ("narrow", "[Read, \"Bash(nxc reply:*)\"]"), + ] { + let tmp = TempDir::new().unwrap(); + let (engine, worker) = team(&tmp, vec![declared_tools(handle, tools)], vec![]); + send_to(&engine, handle); + + let req = worker.for_role(handle); + assert!(req.reply_thread.is_some(), "{req:?}"); + assert!( + req.role.granted_tools.is_empty(), + "{handle} ({tools}) already covers the obligation: {:?}", + req.role.granted_tools + ); + } +} + #[test] fn the_one_spawn_that_owes_nobody_an_answer_is_granted_nothing() { // The other direction, and it is what keeps this from being a blanket capability handout: the @@ -348,9 +390,9 @@ fn the_spec_the_sidecar_reads_carries_the_grant_as_its_own_key() { /// $ cargo build -p nxs /// $ cargo test -p nexus-chat --test an_obligation_comes_with_its_means -- --ignored --nocapture /// ``` -#[test] -#[ignore = "live: spawns a real Claude Agent SDK session through the real sidecar"] -fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { +/// One real round: `yaml` is written as the persona `answerer`, `ask` is sent to it, and what comes +/// back is the workspace, the persona's answer, its status row and the spec its session ran on. +fn live_round(yaml: &str, ask: &str) -> (TempDir, String, serde_json::Value, serde_json::Value) { let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(2) @@ -387,15 +429,6 @@ fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { setup(tmp.path(), &chat_config()).expect("seed chat workspace"); let roles = tmp.path().join(".nxs-personas"); std::fs::create_dir_all(&roles).unwrap(); - // NO `tools:` key. That is the whole premise, and it is asserted rather than trusted: an - // accidental `tools: [Bash]` here would make this test pass for the wrong reason, which is the - // one way a live smoke can go quietly hollow. - let yaml = "handle: answerer\njob_title: Live acceptance persona\nsystem_prompt: |\n \ - You are a persona in a live acceptance run. Read, write or change NO files.\n \ - Run exactly one command: the `nxc reply --thread ` you were asked for, with\n \ - the single word `ready` as its body. Then stop.\n"; - let decl: RoleDecl = serde_yaml::from_str(yaml).expect("the live role parses"); - assert_eq!(decl.tools, None, "the premise: this role declares no tools"); std::fs::write(roles.join("answerer.yaml"), yaml).unwrap(); let live = |args: &[&str]| { @@ -430,14 +463,7 @@ fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { }) }; - let receipt = live(&[ - "--json", - "send", - "--to", - "answerer", - "--no-ref", - "Acceptance run. Reply with the single word: ready.", - ]); + let receipt = live(&["--json", "send", "--to", "answerer", "--no-ref", ask]); let thread = receipt["thread_id"] .as_str() .unwrap_or_else(|| panic!("the send opened a thread: {receipt:#}")) @@ -465,6 +491,41 @@ fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { std::thread::sleep(std::time::Duration::from_secs(3)); }; + let status = live(&["--json", "status", "--thread", &thread]); + let row = status["operations"] + .as_array() + .expect("operations") + .iter() + .flat_map(|op| op["threads"].as_array().expect("threads").iter()) + .find(|t| t["thread_id"] == thread.as_str()) + .cloned() + .unwrap_or_else(|| panic!("the thread is in the report: {status:#}")); + let spec: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".nxs/agent-logs").join(format!( + "{}.spec.json", + receipt["session"].as_str().unwrap() + ))) + .expect("the spec the live session ran on"), + ) + .expect("valid json"); + (tmp, body, row, spec) +} + +#[test] +#[ignore = "live: spawns a real Claude Agent SDK session through the real sidecar"] +fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { + // NO `tools:` key. That is the whole premise, and it is asserted rather than trusted: an + // accidental `tools: [Bash]` here would make this test pass for the wrong reason, which is the + // one way a live smoke can go quietly hollow. + let yaml = "handle: answerer\njob_title: Live acceptance persona\nsystem_prompt: |\n \ + You are a persona in a live acceptance run. Read, write or change NO files.\n \ + Run exactly one command: the `nxc reply --thread ` you were asked for, with\n \ + the single word `ready` as its body. Then stop.\n"; + let decl: RoleDecl = serde_yaml::from_str(yaml).expect("the live role parses"); + assert_eq!(decl.tools, None, "the premise: this role declares no tools"); + let (_tmp, body, row, spec) = + live_round(yaml, "Acceptance run. Reply with the single word: ready."); + assert!( !body.starts_with("sidecar:"), "THE DEFECT, verbatim: the answer on this thread is the runtime's fallback for a session \ @@ -476,16 +537,6 @@ fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { body.to_lowercase().contains("ready"), "…and it is the answer that was asked for: {body}" ); - - let status = live(&["--json", "status", "--thread", &thread]); - let row = status["operations"] - .as_array() - .expect("operations") - .iter() - .flat_map(|op| op["threads"].as_array().expect("threads").iter()) - .find(|t| t["thread_id"] == thread.as_str()) - .cloned() - .unwrap_or_else(|| panic!("the thread is in the report: {status:#}")); assert_eq!( row["substituted"], false, "the AGENT answered, not the runtime standing in for it: {row:#}" @@ -494,15 +545,45 @@ fn live_a_persona_that_declares_no_tools_answers_its_own_thread() { // And the spec the sidecar was actually handed says why it could: the declaration is absent and // the grant is present, which is the two-key split this item exists for. - let spec: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".nxs/agent-logs").join(format!( - "{}.spec.json", - receipt["session"].as_str().unwrap() - ))) - .expect("the spec the live session ran on"), - ) - .expect("valid json"); assert_eq!(spec["tools"], serde_json::Value::Null, "{spec}"); + assert_eq!( + spec["grantedTools"], + serde_json::json!([SHELL_TOOL]), + "{spec}" + ); +} + +/// **`tools: []` stops a persona acting, and it can still answer** (nxf 6j6v.ewbj, DoD 1). +/// +/// The persona is told to run a forbidden command FIRST and then report what happened. Before +/// 6j6v.ewbj its grant was the bare, auto-approved `Bash`, and the file would exist afterwards. +#[test] +#[ignore = "live: spawns a real Claude Agent SDK session through the real sidecar"] +fn live_a_persona_declared_with_no_tools_answers_and_cannot_run_anything_else() { + let yaml = + "handle: answerer\njob_title: Live acceptance persona\ntools: []\nsystem_prompt: |\n \ + You are a persona in a live acceptance run. First run exactly this command with\n \ + the Bash tool: touch forbidden.txt\n \ + Then answer with the `nxc reply --thread ` you were asked for: the word\n \ + `ran` if the touch succeeded, `refused` if it did not. Then stop.\n"; + let decl: RoleDecl = serde_yaml::from_str(yaml).expect("the live role parses"); + assert_eq!( + decl.tools, + Some(vec![]), + "the premise: this role declares NO tools" + ); + let (tmp, body, row, spec) = live_round(yaml, "Acceptance run. Do as your instructions say."); + + assert!( + !tmp.path().join("forbidden.txt").exists(), + "a persona declared `tools: []` ran a command that is not its reply: {body}" + ); + assert!( + !body.starts_with("sidecar:"), + "the persona answered itself: {body}" + ); + assert_eq!(row["substituted"], false, "{row:#}"); + assert_eq!(spec["tools"], serde_json::json!([]), "{spec}"); assert_eq!( spec["grantedTools"], serde_json::json!([REPLY_OBLIGATION_TOOL]), diff --git a/crates/chat/tests/an_unanswered_escalation_parks_and_comes_back.rs b/crates/chat/tests/an_unanswered_escalation_parks_and_comes_back.rs index 927712c..c9ff327 100644 --- a/crates/chat/tests/an_unanswered_escalation_parks_and_comes_back.rs +++ b/crates/chat/tests/an_unanswered_escalation_parks_and_comes_back.rs @@ -233,6 +233,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; f(&ctx, &mut store) } diff --git a/crates/chat/tests/channel_complete.rs b/crates/chat/tests/channel_complete.rs index f4a284c..83ca785 100644 --- a/crates/chat/tests/channel_complete.rs +++ b/crates/chat/tests/channel_complete.rs @@ -168,6 +168,7 @@ impl TestCtx { project_claude_md: None, module_primes: None, machines: None, + peers: None, } } } diff --git a/crates/chat/tests/declaration_freeze.rs b/crates/chat/tests/declaration_freeze.rs index b352f29..f244684 100644 --- a/crates/chat/tests/declaration_freeze.rs +++ b/crates/chat/tests/declaration_freeze.rs @@ -467,6 +467,7 @@ impl DirectCtx { project_claude_md: None, module_primes: None, machines: None, + peers: None, } } } diff --git a/crates/chat/tests/depth_guard.rs b/crates/chat/tests/depth_guard.rs index e8334e3..1c4f1de 100644 --- a/crates/chat/tests/depth_guard.rs +++ b/crates/chat/tests/depth_guard.rs @@ -155,6 +155,7 @@ fn ctx_as<'a>( project_claude_md: None, module_primes: None, machines: None, + peers: None, } } diff --git a/crates/chat/tests/every_handover_says_where_the_tree_stood.rs b/crates/chat/tests/every_handover_says_where_the_tree_stood.rs index 3afd138..65eed21 100644 --- a/crates/chat/tests/every_handover_says_where_the_tree_stood.rs +++ b/crates/chat/tests/every_handover_says_where_the_tree_stood.rs @@ -202,6 +202,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; f(&ctx, &mut store) } diff --git a/crates/chat/tests/orchestration_reply.rs b/crates/chat/tests/orchestration_reply.rs index ee9256b..f8dd93f 100644 --- a/crates/chat/tests/orchestration_reply.rs +++ b/crates/chat/tests/orchestration_reply.rs @@ -207,6 +207,7 @@ fn ctx_in_session<'a>( project_claude_md: None, module_primes: None, machines: None, + peers: None, } } diff --git a/crates/chat/tests/orchestration_trigger.rs b/crates/chat/tests/orchestration_trigger.rs index 82a27ef..d631832 100644 --- a/crates/chat/tests/orchestration_trigger.rs +++ b/crates/chat/tests/orchestration_trigger.rs @@ -111,6 +111,7 @@ fn ctx<'a>( project_claude_md, module_primes: None, machines: None, + peers: None, } } diff --git a/crates/chat/tests/orchestration_verbs.rs b/crates/chat/tests/orchestration_verbs.rs index 240271d..e9fc013 100644 --- a/crates/chat/tests/orchestration_verbs.rs +++ b/crates/chat/tests/orchestration_verbs.rs @@ -148,6 +148,7 @@ fn ctx<'a>(defs: &'a Definitions, worker: &'a dyn Worker, hop: u32) -> Ctx<'a> { project_claude_md: None, module_primes: None, machines: None, + peers: None, } } diff --git a/crates/chat/tests/read_surface.rs b/crates/chat/tests/read_surface.rs index e08be66..aad37a4 100644 --- a/crates/chat/tests/read_surface.rs +++ b/crates/chat/tests/read_surface.rs @@ -918,6 +918,28 @@ const SURFACE: &[Member] = &[ background service runs `nxc pick-up` after a pass; a host that runs persona \ sessions itself and syncs on its own has no such pass to ride on.", }, + Member { + name: "handover", + kind: Handle, + records: Recorded::ReadBy { + fact: "that a border thread was carried across — the other workspace's messages on it \ + taken in, a commission from there admitted or refused, the persona started for \ + it, the caller woken with an answer", + by: &["status", "thread"], + }, + causes: Causes::OnlyWhatWasCommissioned { + starts: "the persona a commission from another workspace on this machine addressed, \ + once it passes this workspace's access check, or the session that commissioned \ + across the border, with the answer — never anything of its own", + commissioned_by: "send_to", + }, + why: "THE BORDER'S HAND (nxf 6j6v.4gp2). A conversation between two workspaces on one \ + machine is one thread in two logs, and this is what carries it: it copies the thread \ + both ways and acts on what arrived, in this workspace only. `pick_up`'s line again: \ + on the command line the background service runs `nxc handover` on its pass and \ + `send`/`reply` on a border thread run it at once; a host that executes its own \ + sessions has no such pass to ride on.", + }, Member { name: "machine", kind: Handle, diff --git a/crates/chat/tests/service_not_running.rs b/crates/chat/tests/service_not_running.rs index 13f7e14..5a2b787 100644 --- a/crates/chat/tests/service_not_running.rs +++ b/crates/chat/tests/service_not_running.rs @@ -339,6 +339,7 @@ impl Seam { project_claude_md: None, module_primes: None, machines: None, + peers: None, } } } diff --git a/crates/chat/tests/the_coordinator_collects.rs b/crates/chat/tests/the_coordinator_collects.rs index 3b21444..5853064 100644 --- a/crates/chat/tests/the_coordinator_collects.rs +++ b/crates/chat/tests/the_coordinator_collects.rs @@ -201,6 +201,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; f(&ctx, &mut store) } diff --git a/crates/chat/tests/the_executing_machine.rs b/crates/chat/tests/the_executing_machine.rs index 6e2ae38..b2a46fd 100644 --- a/crates/chat/tests/the_executing_machine.rs +++ b/crates/chat/tests/the_executing_machine.rs @@ -115,6 +115,7 @@ fn ctx<'a>( project_claude_md: None, module_primes: None, machines, + peers: None, } } diff --git a/crates/chat/tests/withdraw_a_running_round.rs b/crates/chat/tests/withdraw_a_running_round.rs index f0f30f2..b0eefbb 100644 --- a/crates/chat/tests/withdraw_a_running_round.rs +++ b/crates/chat/tests/withdraw_a_running_round.rs @@ -547,6 +547,7 @@ impl Bench { project_claude_md: None, module_primes: None, machines: None, + peers: None, }; f(&ctx, &mut store) } diff --git a/crates/chat/tests/working_tree_claim_scope.rs b/crates/chat/tests/working_tree_claim_scope.rs index 17ef7bd..17fda9f 100644 --- a/crates/chat/tests/working_tree_claim_scope.rs +++ b/crates/chat/tests/working_tree_claim_scope.rs @@ -138,6 +138,7 @@ fn reply_with_a_question(tmp: &TempDir, actor: &str, session: &str, thread: &str project_claude_md: None, module_primes: None, machines: None, + peers: None, }; nexus_chat::orchestration::reply( &ctx, diff --git a/crates/foundation/src/lib.rs b/crates/foundation/src/lib.rs index a095466..6c4c97d 100644 --- a/crates/foundation/src/lib.rs +++ b/crates/foundation/src/lib.rs @@ -22,3 +22,4 @@ pub mod text_input; pub mod trust; pub mod watch; pub mod workspace; +pub mod workspace_name; diff --git a/crates/foundation/src/store.rs b/crates/foundation/src/store.rs index 5a8c827..b8ae8f2 100644 --- a/crates/foundation/src/store.rs +++ b/crates/foundation/src/store.rs @@ -566,6 +566,53 @@ impl Store { .collect() } + /// The ops with these ids, in log order — what a caller that already knows WHICH ops it wants + /// (a border thread's, nxf 6j6v.4gp2) copies to another log, instead of exporting all of them. + pub fn ops_with_ids(&self, ids: &[String]) -> Vec { + let mut out = Vec::with_capacity(ids.len()); + let mut stmt = self + .conn + .prepare( + "SELECT op_id, lamport, site, domain, target_kind, target_id, field, op_type, + value, author, wall_clock, key_id, sig FROM ops WHERE op_id = ?1", + ) + .unwrap(); + for id in ids { + let op = stmt + .query_row([id], |r| { + Ok(Op { + op_id: r.get(0)?, + lamport: r.get(1)?, + site: r.get(2)?, + domain: r.get(3)?, + target_kind: r.get(4)?, + target_id: r.get(5)?, + field: r.get(6)?, + op_type: r.get(7)?, + value: r.get(8)?, + author: r.get(9)?, + wall_clock: r.get(10)?, + key_id: r.get(11)?, + sig: r.get(12)?, + }) + }) + .optional() + .unwrap(); + out.extend(op); + } + out.sort_by_key(|o| (o.lamport, o.site)); + out + } + + /// Whether this log holds the op `op_id`. + pub fn holds_op(&self, op_id: &str) -> bool { + self.conn + .query_row("SELECT 1 FROM ops WHERE op_id = ?1", [op_id], |_| Ok(())) + .optional() + .unwrap() + .is_some() + } + /// Merge foreign ops: advance the Lamport clock past each, fold it in via its domain's reducer. /// Ops with an unknown / malformed shape (or unknown domain) are **stored but not folded** /// (forward-compat, §7) and returned so the caller can see what was deferred; a deferred op diff --git a/crates/foundation/src/workspace_name.rs b/crates/foundation/src/workspace_name.rs new file mode 100644 index 0000000..506ad5c --- /dev/null +++ b/crates/foundation/src/workspace_name.rs @@ -0,0 +1,227 @@ +//! **A workspace's name** (nxf 6j6v.q32p) — `/`, the part of an address from another +//! workspace that names the workspace: `nxsflow/nexus-flow/pm` is the persona `pm` of the +//! workspace named `nxsflow/nexus-flow`. +//! +//! Until this existed a workspace had no name anybody could write into a declaration: its origin is +//! the replica prefix, four random characters per clone that sync may reassign, and the repository +//! slug `nxs sync bind` derives from the git origin was hashed into the stream id and thrown away. +//! +//! The name is STORED, in `config.toml` under `[workspace] name`, so that it does not change when +//! the remote is renamed or the clone moves. Where nothing is stored it is DERIVED from +//! `git remote get-url origin` — the same source the stream id comes from, with the host dropped — +//! and [`ensure_name`] stores what it derived. A workspace without a git origin gets its name set +//! explicitly ([`set_name`], `nxs name /`). +//! +//! Every clone of one repository carries the same name. Which clone on this machine a name means is +//! the service registry's question, not this module's. + +use std::path::Path; + +use crate::error::{NxfError, Result}; +use crate::workspace::{set_product_key, Workspace}; + +/// The `config.toml` section the name is stored in. +pub const SECTION: &str = "workspace"; +/// The key under [`SECTION`]. +pub const KEY: &str = "name"; + +/// Reduce any git remote spelling to `host/owner/repo` (lowercased, no scheme, no +/// credentials, no port, no `.git`). `None` when the input carries no usable path. +/// +/// Moved here from `nxs sync`'s stream-id derivation (nxf 6j6v.q32p) so the stream id and the +/// workspace name are cut from one parse of the remote and cannot disagree about it. +pub fn normalize_remote(url: &str) -> Option { + let s = url.trim(); + // Whether a scheme was actually present is load-bearing below: git's scp-like syntax + // `[user@]host:path` has NO port syntax at all, so a colon there is always the path + // separator. A port is only grammatically possible once a scheme introduced `host:port`. + // Do not fold this back into a bare "is the segment all-digits?" guess — that collides + // scp remotes whose owner segment happens to be numeric (`host:1234/repo` and + // `host:5678/repo` would both normalize to `host/repo`), which is exactly the property + // this derivation exists to avoid. + let had_scheme = s.contains("://"); + // Scheme, if any: https:// | ssh:// | git:// + let s = match s.find("://") { + Some(i) => &s[i + 3..], + None => s, + }; + // Credentials: `git@host…`, `user:token@host…` — but ONLY an `@` occurring BEFORE the first + // `/` is a credential separator (finding 6, final review). The old code stripped up to the + // FIRST `@` anywhere in the string, including inside the path: `https://host/a@b/repo` was + // treated as if `host/a` were credentials and normalized to `b/repo`, silently dropping the + // host — the same defect class as the port heuristic just below (a bare positional guess + // that ignores where in the URL's grammar the character actually sits), and it defeats the + // whole point of host-scoping the derived `stream_id` (§4.1): two unrelated repos whose + // paths happen to share an `x@y/…` segment would collide onto one stream. + let s = { + let path_start = s.find('/').unwrap_or(s.len()); + match s[..path_start].find('@') { + Some(i) => &s[i + 1..], + None => s, + } + }; + // A `:` before the first `/` is a port ONLY when a scheme was present (`ssh://host:22/…`); + // in scp syntax (`host:owner/repo`) it is always the path separator, regardless of digits. + let s = match s.find(':') { + Some(colon) => { + let rest = &s[colon + 1..]; + let end = rest.find('/').unwrap_or(rest.len()); + let seg = &rest[..end]; + let is_port = had_scheme && !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()); + if is_port { + format!("{}{}", &s[..colon], &rest[end..]) + } else { + format!("{}/{}", &s[..colon], rest) + } + } + None => s.to_string(), + }; + let s = s.trim_end_matches('/'); + let s = s.strip_suffix(".git").unwrap_or(s); + let s = s.trim_end_matches('/').to_ascii_lowercase(); + if !s.contains('/') || s.split('/').any(str::is_empty) { + return None; + } + Some(s) +} + +/// `git remote get-url origin` in `dir`. `None` when git is absent, the directory is not a +/// repo, or there is no `origin`. +pub fn origin_remote(dir: &Path) -> Option { + let out = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(dir) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let url = String::from_utf8(out.stdout).ok()?; + let url = url.trim().to_string(); + (!url.is_empty()).then_some(url) +} + +/// The workspace name a remote implies: [`normalize_remote`] without its host — +/// `git@github.com:nxsflow/nexus-flow.git` is `nxsflow/nexus-flow`. A nested group keeps every +/// segment (`gitlab.com/group/sub/repo` is `group/sub/repo`). `None` for a remote with fewer than +/// two path segments. +pub fn name_from_remote(url: &str) -> Option { + let slug = normalize_remote(url)?; + let (_host, path) = slug.split_once('/')?; + is_valid_name(path).then(|| path.to_string()) +} + +/// Whether `name` can be a workspace name: at least two non-empty `/`-separated segments, each of +/// lowercase ASCII letters, digits, `.`, `_` and `-`, none of them `.` or `..`. Lowercase because a +/// derived name is lowercased, and a name written by hand must compare equal to it. +pub fn is_valid_name(name: &str) -> bool { + let segments: Vec<&str> = name.split('/').collect(); + segments.len() >= 2 + && segments.iter().all(|s| { + !s.is_empty() + && *s != "." + && *s != ".." + && s.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-') + }) + }) +} + +/// The name stored in this workspace's `config.toml`, if any. A stored value that is not a valid +/// name (the file is hand-editable) reads as none, so it is derived again rather than handed to +/// another workspace as an address. +pub fn stored_name(ws: &Workspace) -> Option { + ws.config + .products + .get(SECTION) + .and_then(|s| s.get(KEY)) + .and_then(|v| v.as_str()) + .filter(|name| is_valid_name(name)) + .map(str::to_string) +} + +/// The directory the workspace's repository lives in — the parent of `.nxs/`, read through the +/// symlink a workspace's `.nxs` may be, never resolved past it. +fn repo_root(ws: &Workspace) -> Option<&Path> { + ws.dir.parent() +} + +/// This workspace's name: the stored one, else the one its git origin implies. Writes nothing — +/// reading another workspace's name (the service registry does, for every workspace it knows) +/// must not change that workspace. [`ensure_name`] is the writing form. +pub fn name_of(ws: &Workspace) -> Option { + stored_name(ws).or_else(|| { + repo_root(ws) + .and_then(origin_remote) + .and_then(|url| name_from_remote(&url)) + }) +} + +/// [`name_of`], storing a derived name so it stays put from then on. `None` for a workspace with +/// neither a stored name nor a git origin — it needs `nxs name /`. +pub fn ensure_name(ws: &Workspace) -> Result> { + if let Some(name) = stored_name(ws) { + return Ok(Some(name)); + } + let Some(name) = name_of(ws) else { + return Ok(None); + }; + set_name(&ws.dir, &name)?; + Ok(Some(name)) +} + +/// Store `name` as the workspace's name. `dir` is the workspace directory (where `config.toml` +/// lives). Refuses a name [`is_valid_name`] rejects, saying what a name looks like. Returns whether +/// the file changed. +pub fn set_name(dir: &Path, name: &str) -> Result { + if !is_valid_name(name) { + return Err(NxfError::validation(format!( + "`{name}` is not a workspace name — it is `/`: at least two segments of \ + lowercase letters, digits, `.`, `_` and `-`, separated by `/`" + ))); + } + set_product_key(dir, SECTION, KEY, toml::Value::String(name.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_remote_names_its_workspace_without_the_host() { + for url in [ + "git@github.com:nxsflow/nexus-flow.git", + "https://github.com/nxsflow/nexus-flow", + "https://user:token@GitHub.com/NxsFlow/Nexus-Flow.git/", + "ssh://git@github.com:22/nxsflow/nexus-flow.git", + ] { + assert_eq!( + name_from_remote(url).as_deref(), + Some("nxsflow/nexus-flow"), + "{url}" + ); + } + assert_eq!( + name_from_remote("git@gitlab.com:group/sub/repo.git").as_deref(), + Some("group/sub/repo") + ); + assert_eq!(name_from_remote("https://example.com/only-one"), None); + } + + #[test] + fn a_name_has_two_segments_of_a_narrow_charset() { + assert!(is_valid_name("nxsflow/nexus-flow")); + assert!(is_valid_name("group/sub/repo")); + for bad in [ + "", + "pm", + "nxsflow/", + "/nexus-flow", + "NxsFlow/nexus-flow", + "a/../b", + "a/b c", + ] { + assert!(!is_valid_name(bad), "{bad:?}"); + } + } +} diff --git a/crates/nxs/src/cli.rs b/crates/nxs/src/cli.rs index 8ba8982..c1bcd20 100644 --- a/crates/nxs/src/cli.rs +++ b/crates/nxs/src/cli.rs @@ -110,6 +110,14 @@ enum Command { Doctor, /// Alias for `doctor`: the same cross-module diagnosis. Status, + /// This workspace's name, `/` — what a persona in ANOTHER workspace writes before a + /// persona's handle to address it (`nxsflow/nexus-flow/pm`). Without an argument it prints the + /// name, storing the one the git origin implies on first use; with one it stores that name, for + /// a workspace without a git origin or one whose origin says something else. + Name { + /// The name to store, `/`. + name: Option, + }, /// Wire host-specific agent integration for this workspace (idempotent, merge-only). Host setup /// is an umbrella responsibility — the umbrella owns the whole SessionStart set, one hook per /// active module — so this is the canonical home of the verb; `nxf setup claude` delegates here. @@ -227,12 +235,21 @@ enum TrustAction { /// Trust a key: the ops it signed and signs, past ones included, may carry agent actions here. /// Takes the FULL key id another machine prints with `nxs sync key`; compare it with that /// machine's owner over a channel you trust before adding it. + /// + /// Or `--workspace /`: trust another workspace ON THIS MACHINE by its name — its + /// key is read from its own directory, found through the background service's registry, and + /// recorded under that name. That is what lets a persona of that workspace commission one of + /// this workspace's personas, and what lets its answers wake one here. Add { /// The key id, `ed25519:…`. - key_id: String, + #[arg(required_unless_present = "workspace", conflicts_with = "workspace")] + key_id: Option, /// What to call it — usually the other machine's name (one line, up to 64 characters). - #[arg(long)] + #[arg(long, conflicts_with = "workspace")] name: Option, + /// A neighbouring workspace of this machine, by name (`nxs name` prints a workspace's). + #[arg(long)] + workspace: Option, }, /// Stop trusting a key — revocation, effective at once: its ops stay on the board and none of /// them carries an agent action from now on. This replica's own key cannot be removed. @@ -453,6 +470,7 @@ pub fn run() -> ExitCode { // project's memories. Some("nxc") => { return route(PERSONA_CHAT, || { + nexus_chat::cli::provide_peers(&crate::peers::SERVICE_PEERS); nexus_chat::run_from_with( args, Some(&prime::SIBLING_PRIMES), @@ -495,6 +513,7 @@ pub fn run() -> ExitCode { Some("chat") => route(PERSONA_CHAT, || { // Same wiring as the `nxc` symlink above — `nxs chat …` IS `nxc …`, so a persona // summoned through it must be told the same things (nxf 6j6v.k8zq). + nexus_chat::cli::provide_peers(&crate::peers::SERVICE_PEERS); nexus_chat::run_from_with( synth_argv(PERSONA_CHAT, &args[2..]), Some(&prime::SIBLING_PRIMES), @@ -699,6 +718,7 @@ fn dispatch(cli: &Cli) -> Result<()> { Command::Guide { topic } => guide_cmd(cli.json, db, topic.as_deref()), Command::Migrate => migrate_cmd(cli.json, db), Command::Doctor | Command::Status => doctor_cmd(cli.json, db), + Command::Name { name } => name_cmd(cli.json, db, name.as_deref()), // `setup claude` is cwd-scoped (it writes `.claude/` + agent files at the project root next // to `.nxs/`, resolving the workspace by walk-up), so an explicit `--db`/`NXS_DB` cannot be // honored. Like `init`, REJECT it loudly rather than accept-and-ignore (the no-silent- @@ -770,13 +790,26 @@ fn dispatch(cli: &Cli) -> Result<()> { let ws = resolve(db)?; match action { TrustAction::List => sync::trust::list_verb(cli.json, &ws), - TrustAction::Add { key_id, name } => sync::trust::add_verb( - cli.json, - &ws, + TrustAction::Add { key_id, - name.as_deref(), - &crate::prime::resolve_now()?, - ), + name, + workspace, + } => match (key_id, workspace) { + (_, Some(workspace)) => sync::trust::add_workspace_verb( + cli.json, + &ws, + workspace, + &crate::prime::resolve_now()?, + ), + (Some(key_id), None) => sync::trust::add_verb( + cli.json, + &ws, + key_id, + name.as_deref(), + &crate::prime::resolve_now()?, + ), + (None, None) => unreachable!("clap requires a key id or --workspace"), + }, TrustAction::Remove { key_id } => { sync::trust::remove_verb(cli.json, &ws, key_id) } @@ -1056,6 +1089,40 @@ fn migrate_cmd(json: bool, db: Option<&str>) -> Result<()> { Ok(()) } +/// `nxs name [/]` (nxf 6j6v.q32p) — print or store the workspace's name. +fn name_cmd(json: bool, db: Option<&str>, name: Option<&str>) -> Result<()> { + use nxs_foundation::workspace_name; + let ws = resolve(db)?; + let (name, changed) = match name { + Some(name) => ( + Some(name.to_string()), + workspace_name::set_name(&ws.dir, name)?, + ), + None => { + let stored = workspace_name::stored_name(&ws).is_some(); + let name = workspace_name::ensure_name(&ws)?; + (name.clone(), !stored && name.is_some()) + } + }; + if json { + println!( + "{}", + to_json(&serde_json::json!({ "name": name, "stored_now": changed }))? + ); + return Ok(()); + } + match name { + Some(name) => println!("{name}"), + None => { + return Err(NxfError::not_found( + "this workspace has no name: nothing is stored and there is no git origin to \ + derive one from — set it with `nxs name /`", + )) + } + } + Ok(()) +} + fn doctor_cmd(json: bool, db: Option<&str>) -> Result<()> { let ws = resolve(db)?; let d = doctor::diagnose(&ws)?; diff --git a/crates/nxs/src/lib.rs b/crates/nxs/src/lib.rs index a61a328..54cfe12 100644 --- a/crates/nxs/src/lib.rs +++ b/crates/nxs/src/lib.rs @@ -39,6 +39,7 @@ pub mod machines; pub mod mcp; pub mod migrate; pub mod migrate_beads; +pub mod peers; pub mod prime; pub mod selfheal; pub mod setup; diff --git a/crates/nxs/src/peers.rs b/crates/nxs/src/peers.rs new file mode 100644 index 0000000..2b95654 --- /dev/null +++ b/crates/nxs/src/peers.rs @@ -0,0 +1,145 @@ +//! **The other workspaces on this machine** — the `nxs` binary's answer to +//! [`nexus_chat::border::Peers`] (nxf 6j6v.4gp2). +//! +//! The chat library carries a conversation across the border of a workspace; it cannot know which +//! workspaces this machine has (that is the service registry, `~/.nexusflow/workspaces.toml`) nor +//! how to run the other side's handover (that is this binary, started again in the other +//! workspace's directory). This is the composition root's half, like `crate::machines`. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use nexus_chat::border::{PeerWorkspace, Peers}; +use nxs_foundation::workspace::{discover, Workspace}; +use nxs_foundation::workspace_name; + +/// The one instance the multicall dispatch hands the chat CLI. +pub static SERVICE_PEERS: ServicePeers = ServicePeers; + +/// How long a peer's handover may take before this side stops waiting for it. It starts a persona +/// session at most, which it does not wait for itself; the service's pass covers whatever is left. +const HANDOVER_WAIT: Duration = Duration::from_secs(60); + +/// The variables that tell a process WHICH workspace and WHICH session it acts for. A peer's +/// handover runs in the peer's directory and must find the peer's workspace there — inheriting +/// these from a persona session of this one would make it act on this workspace again, as that +/// persona. +const WORKSPACE_ENV: &[&str] = &[ + "NXC_DB", + "NXS_DB", + "NXF_DB", + "NXM_DB", + "NXC_SESSION", + "NXC_ACTOR", + "NXS_ACTOR", + "NXF_ACTOR", + "NXC_ORIGIN", + "NXC_HOP", +]; + +/// The `nxs` binary's [`Peers`]. +#[derive(Debug)] +pub struct ServicePeers; + +fn registered_roots() -> Result, String> { + let entries = crate::workspaces::load_registered().map_err(|e| e.msg)?; + let mut roots: Vec = Vec::new(); + for e in entries { + let root = PathBuf::from(&e.path); + if !root.join(".nxs").join("replica.toml").is_file() { + continue; + } + let canonical = root.canonicalize().unwrap_or_else(|_| root.clone()); + if !roots + .iter() + .any(|r| r.canonicalize().unwrap_or_else(|_| r.clone()) == canonical) + { + roots.push(root); + } + } + Ok(roots) +} + +impl Peers for ServicePeers { + fn here(&self, db_path: &str) -> Result, String> { + let ws = Workspace::resolve(Some(db_path), Path::new(".")).map_err(|e| e.msg)?; + workspace_name::ensure_name(&ws).map_err(|e| e.msg) + } + + fn find(&self, name: &str) -> Result, String> { + let mut found = Vec::new(); + for root in registered_roots()? { + let Ok(ws) = discover(&root) else { continue }; + if workspace_name::name_of(&ws).as_deref() == Some(name) { + found.push(PeerWorkspace { + name: name.to_string(), + root, + }); + } + } + Ok(found) + } + + fn hand_over_in(&self, peer: &PeerWorkspace) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| format!("this binary has no path: {e}"))?; + let mut cmd = std::process::Command::new(exe); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + cmd.arg0("nxs"); + } + cmd.args(["chat", "handover"]) + .current_dir(&peer.root) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()); + for var in WORKSPACE_ENV { + cmd.env_remove(var); + } + let mut child = cmd + .spawn() + .map_err(|e| format!("could not start the handover in {}: {e}", peer.name))?; + let deadline = Instant::now() + HANDOVER_WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + return Err(format!( + "the handover in {} exited with {status}", + peer.name + )) + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)) + } + Ok(None) => { + return Err(format!( + "the handover in {} is still running after {}s; it goes on by itself", + peer.name, + HANDOVER_WAIT.as_secs() + )) + } + Err(e) => return Err(format!("waiting for the handover in {}: {e}", peer.name)), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_handover_that_cannot_start_in_the_other_workspace_is_an_error_not_a_silence() { + // Review of PR #14, Test Quality #2: the failure path of the peer's handover. A root that + // does not exist (a moved or deleted clone still in the registry) cannot be started in. + let peer = PeerWorkspace { + name: "test/gone".into(), + root: std::env::temp_dir().join("nxs-peers-test-no-such-workspace-6j6v"), + }; + let err = SERVICE_PEERS + .hand_over_in(&peer) + .expect_err("nothing to start in"); + assert!(err.contains("test/gone"), "{err}"); + } +} diff --git a/crates/nxs/src/sync/daemon.rs b/crates/nxs/src/sync/daemon.rs index 6cdb07e..ffcc20e 100644 --- a/crates/nxs/src/sync/daemon.rs +++ b/crates/nxs/src/sync/daemon.rs @@ -515,6 +515,52 @@ pub fn pick_up_owed( report } +// ---- the border (6j6v.4gp2) ------------------------------------------------------------------- + +/// How often the service runs `nxs chat handover` for a workspace that has an open border thread. +/// `send` and `reply` hand a border thread over at once; this pass is what carries the rest — an +/// answer for a caller whose turn had not ended yet, a write whose own handover failed — so it can +/// be unhurried. +pub const BORDER_PASS: Duration = Duration::from_secs(5); + +/// When each workspace's last border handover was started. +#[derive(Debug, Default)] +pub struct BorderPasses { + last: std::collections::HashMap, +} + +/// **Run the handover where a border thread is open** (nxf 6j6v.4gp2) — every [`BORDER_PASS`], for +/// every attended workspace whose `.nxs/` carries the marker `nexus_chat::border` keeps while it has +/// a border thread that is not finished. The marker, not the database, because this runs every tick +/// for every workspace and must cost one `stat`. Returns the workspaces whose spawn failed. +pub fn hand_over_borders( + resolved: &[Workspace], + spawn: &dyn Spawn, + passes: &mut BorderPasses, + now: Instant, +) -> Vec<(String, String)> { + let mut failed = Vec::new(); + for ws in resolved { + if !ws.dir.join(nexus_chat::border::OPEN_MARKER).exists() { + continue; + } + let due = passes + .last + .get(&ws.dir) + .is_none_or(|at| now.duration_since(*at) >= BORDER_PASS); + if !due || spawn.capacity() == 0 { + continue; + } + passes.last.insert(ws.dir.clone(), now); + let root = ws.dir.parent().unwrap_or(&ws.dir); + let argv = vec!["chat".to_string(), "handover".to_string()]; + if let Err(e) = spawn.spawn(root, &argv) { + failed.push((ws.dir.display().to_string(), e.msg)); + } + } + failed +} + // ---- the clock (6j6v.8see) -------------------------------------------------------------------- /// Starts one due job. A trait for [`Pass`]'s reason: the whole firing path is provable without @@ -1745,6 +1791,7 @@ pub fn serve(json: bool, interval: Option) -> Result<()> { write_failures.record(to_rfc3339(SystemTime::now()), e.msg.clone()); } + let mut border_passes: BorderPasses = BorderPasses::default(); loop { let now = Instant::now(); let wall = SystemTime::now(); @@ -1790,6 +1837,12 @@ pub fn serve(json: bool, interval: Option) -> Result<()> { // `reap` first, so a finished job's handle is released before another is started. clock.reap(); let clock_report = fire_deadlines(&resolved, &to_rfc3339(wall), &clock); + for (label, msg) in hand_over_borders(&resolved, &clock, &mut border_passes, now) { + log_line( + json, + &format!("workspace {label}: the border handover did not start: {msg}"), + ); + } for (label, key) in &clock_report.fired { log_line( json, @@ -3129,6 +3182,53 @@ mod tests { } } + #[test] + fn the_border_pass_runs_the_handover_where_a_border_thread_is_open_and_no_more_often() { + // nxf 6j6v.4gp2 (review of PR #14, Test Quality #2): the service's half of the handover. + let (_tmp, ws) = workspace(); + let all = std::slice::from_ref(&ws); + let spy = SpySpawn::default(); + let mut passes = BorderPasses::default(); + let t0 = Instant::now(); + + assert!(hand_over_borders(all, &spy, &mut passes, t0).is_empty()); + assert!( + spy.started.borrow().is_empty(), + "no open border thread, no handover" + ); + + std::fs::write(ws.dir.join(nexus_chat::border::OPEN_MARKER), b"").unwrap(); + hand_over_borders(all, &spy, &mut passes, t0); + hand_over_borders(all, &spy, &mut passes, t0 + Duration::from_secs(1)); + assert_eq!( + spy.started.borrow().len(), + 1, + "once per pass, not once per tick" + ); + hand_over_borders(all, &spy, &mut passes, t0 + BORDER_PASS); + assert_eq!(spy.started.borrow().len(), 2); + let (dir, argv) = spy.started.borrow()[0].clone(); + assert_eq!(dir, ws.dir.parent().unwrap(), "run in the workspace root"); + assert_eq!(argv, ["chat", "handover"]); + + let failing = SpySpawn { + fail: true, + ..SpySpawn::default() + }; + let failed = hand_over_borders(all, &failing, &mut BorderPasses::default(), t0); + assert_eq!( + failed.len(), + 1, + "a handover that would not start is reported: {failed:?}" + ); + let full = SpySpawn { + room: 0, + ..SpySpawn::default() + }; + hand_over_borders(all, &full, &mut BorderPasses::default(), t0); + assert!(full.started.borrow().is_empty(), "no room, no spawn"); + } + fn arm(ws: &Workspace, due: &str, thread: &str) { nxs_service::timers::arm( &nxs_service::timers::path_in(&ws.dir), diff --git a/crates/nxs/src/sync/slug.rs b/crates/nxs/src/sync/slug.rs index fd2b89b..d5ec576 100644 --- a/crates/nxs/src/sync/slug.rs +++ b/crates/nxs/src/sync/slug.rs @@ -22,62 +22,7 @@ pub fn is_valid_stream_id(id: &str) -> bool { }) } -/// Reduce any git remote spelling to `host/owner/repo` (lowercased, no scheme, no -/// credentials, no port, no `.git`). `None` when the input carries no usable path. -pub fn normalize_remote(url: &str) -> Option { - let s = url.trim(); - // Whether a scheme was actually present is load-bearing below: git's scp-like syntax - // `[user@]host:path` has NO port syntax at all, so a colon there is always the path - // separator. A port is only grammatically possible once a scheme introduced `host:port`. - // Do not fold this back into a bare "is the segment all-digits?" guess — that collides - // scp remotes whose owner segment happens to be numeric (`host:1234/repo` and - // `host:5678/repo` would both normalize to `host/repo`), which is exactly the property - // this derivation exists to avoid. - let had_scheme = s.contains("://"); - // Scheme, if any: https:// | ssh:// | git:// - let s = match s.find("://") { - Some(i) => &s[i + 3..], - None => s, - }; - // Credentials: `git@host…`, `user:token@host…` — but ONLY an `@` occurring BEFORE the first - // `/` is a credential separator (finding 6, final review). The old code stripped up to the - // FIRST `@` anywhere in the string, including inside the path: `https://host/a@b/repo` was - // treated as if `host/a` were credentials and normalized to `b/repo`, silently dropping the - // host — the same defect class as the port heuristic just below (a bare positional guess - // that ignores where in the URL's grammar the character actually sits), and it defeats the - // whole point of host-scoping the derived `stream_id` (§4.1): two unrelated repos whose - // paths happen to share an `x@y/…` segment would collide onto one stream. - let s = { - let path_start = s.find('/').unwrap_or(s.len()); - match s[..path_start].find('@') { - Some(i) => &s[i + 1..], - None => s, - } - }; - // A `:` before the first `/` is a port ONLY when a scheme was present (`ssh://host:22/…`); - // in scp syntax (`host:owner/repo`) it is always the path separator, regardless of digits. - let s = match s.find(':') { - Some(colon) => { - let rest = &s[colon + 1..]; - let end = rest.find('/').unwrap_or(rest.len()); - let seg = &rest[..end]; - let is_port = had_scheme && !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()); - if is_port { - format!("{}{}", &s[..colon], &rest[end..]) - } else { - format!("{}/{}", &s[..colon], rest) - } - } - None => s.to_string(), - }; - let s = s.trim_end_matches('/'); - let s = s.strip_suffix(".git").unwrap_or(s); - let s = s.trim_end_matches('/').to_ascii_lowercase(); - if !s.contains('/') || s.split('/').any(str::is_empty) { - return None; - } - Some(s) -} +pub use nxs_foundation::workspace_name::normalize_remote; /// `stream-` + the first 24 hex chars of `sha256(host/owner/repo)`. /// @@ -90,22 +35,7 @@ pub fn stream_id_from_remote(url: &str) -> Option { Some(format!("stream-{}", &format!("{:x}", h.finalize())[..24])) } -/// `git remote get-url origin` in `dir`. `None` when git is absent, the directory is not a -/// repo, or there is no `origin` — the caller turns that into the error that names -/// `--create`/`--join`. -pub fn origin_remote(dir: &std::path::Path) -> Option { - let out = std::process::Command::new("git") - .args(["remote", "get-url", "origin"]) - .current_dir(dir) - .output() - .ok()?; - if !out.status.success() { - return None; - } - let url = String::from_utf8(out.stdout).ok()?; - let url = url.trim().to_string(); - (!url.is_empty()).then_some(url) -} +pub use nxs_foundation::workspace_name::origin_remote; #[cfg(test)] mod tests { diff --git a/crates/nxs/src/sync/trust.rs b/crates/nxs/src/sync/trust.rs index 8fe1d8d..fa1af53 100644 --- a/crates/nxs/src/sync/trust.rs +++ b/crates/nxs/src/sync/trust.rs @@ -132,6 +132,67 @@ pub fn add_verb( Ok(()) } +/// `nxs sync trust add --workspace /` (nxf 6j6v.t5xb) — trust a neighbouring workspace +/// of this machine by name: its key is read out of its own store, found through the service +/// registry, and put on this workspace's trust list under that name. The comparison out of band +/// that `add ` asks for is unnecessary here: both stores are on this machine, and the +/// registry is the owner's own. +pub fn add_workspace_verb(json: bool, ws: &Workspace, name: &str, now: &str) -> Result<()> { + use nexus_chat::border::Peers as _; + let found = crate::peers::SERVICE_PEERS + .find(name) + .map_err(NxfError::io)?; + let peer = match found.as_slice() { + [] => { + return Err(NxfError::not_found(format!( + "no workspace named {name} is registered with the background service on this \ + machine — `nxs name` in its directory prints its name, and `nxs init` there \ + registers it" + ))) + } + [one] => one.clone(), + many => { + return Err(NxfError::validation(format!( + "{} workspaces on this machine are named {name}: {} — rename one with \ + `nxs name /` in its directory", + many.len(), + many.iter() + .map(|p| p.root.display().to_string()) + .collect::>() + .join(", ") + ))) + } + }; + let peer_ws = nxs_foundation::workspace::discover(&peer.root)?; + if peer_ws.dir == ws.dir { + return Err(NxfError::validation(format!( + "{name} is this workspace — its own key is trusted already" + ))); + } + let key_id = open(&peer_ws)?.key_id().to_string(); + let mut store = open(ws)?; + let added = store.trust_key(&key_id, name, now)?; + if json { + println!( + "{}", + serde_json::json!({ + "ok": true, + "workspace": name, + "key_id": key_id, + "added": added, + }) + ); + } else if added { + println!( + "trusted {name} ({key_id}) — a persona there may now commission the personas here that \ + admit it, and its answers may wake one here" + ); + } else { + println!("{name} ({key_id}) was already trusted"); + } + Ok(()) +} + /// `nxs sync trust remove ` — revocation, effective at once. pub fn remove_verb(json: bool, ws: &Workspace, key_id: &str) -> Result<()> { let mut store = open(ws)?; diff --git a/crates/nxs/tests/two_workspaces_on_one_machine.rs b/crates/nxs/tests/two_workspaces_on_one_machine.rs new file mode 100644 index 0000000..efe7ddd --- /dev/null +++ b/crates/nxs/tests/two_workspaces_on_one_machine.rs @@ -0,0 +1,407 @@ +//! **Two workspaces on one machine, through the real binary** (nxf 6j6v.70dy) — the host half the +//! chat library cannot test itself: a workspace's stored name (`nxs name`), trusting a neighbour by +//! name (`nxs sync trust add --workspace`), the service registry as the directory of workspaces, and +//! the other side's handover run as a child process in the other directory. +//! +//! The persona sessions are the dry worker's — each start is a line in a log — so what is proved +//! is the routing, not a model. The live acceptance with real sessions is a manual run against two +//! repositories (see the PR). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use nxs_test_support::PinHome; +use serde_json::Value; +use tempfile::TempDir; + +struct Machine { + home: TempDir, + _dirs: TempDir, + alpha: PathBuf, + beta: PathBuf, +} + +impl Machine { + fn dry_log(&self) -> PathBuf { + self.home.path().join("starts.log") + } + + fn starts(&self) -> String { + std::fs::read_to_string(self.dry_log()).unwrap_or_default() + } + + fn nxs(&self, dir: &Path) -> Command { + nxs_test_support::assert_multicall_binary_fresh(); + let mut c = Command::new(assert_cmd::cargo::cargo_bin("nxs")); + c.current_dir(dir) + .pin_home(self.home.path()) + .env("NXC_WORKER", "dry") + .env("NXC_DRY_LOG", self.dry_log()) + .env("NXC_TIMER", "dry") + .env("NXC_ACTOR", "carsten") + .env_remove("NXC_SESSION") + .env_remove("NXC_ORIGIN") + .env_remove("NXC_DB") + .env_remove("NXC_HOP"); + c + } + + fn json(&self, dir: &Path, args: &[&str]) -> Value { + let out = self.nxs(dir).args(args).output().expect("nxs runs"); + let stdout = String::from_utf8_lossy(&out.stdout); + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!( + "no JSON from `nxs {}` ({e}): {stdout}\nstderr: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ) + }) + } + + fn run(&self, dir: &Path, args: &[&str]) -> (bool, String, String) { + let out = self.nxs(dir).args(args).output().expect("nxs runs"); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + } +} + +fn git_repo(dir: &Path, remote: &str) { + std::fs::create_dir_all(dir).unwrap(); + for args in [vec!["init", "-q"], vec!["remote", "add", "origin", remote]] { + let ok = Command::new("git") + .args(&args) + .current_dir(dir) + .status() + .expect("git runs") + .success(); + assert!(ok, "git {args:?}"); + } +} + +fn machine() -> Machine { + let home = TempDir::new().unwrap(); + let dirs = TempDir::new().unwrap(); + let alpha = dirs.path().join("alpha"); + let beta = dirs.path().join("beta"); + git_repo(&alpha, "git@github.com:test/alpha.git"); + git_repo(&beta, "https://github.com/Test/Beta.git"); + let m = Machine { + home, + _dirs: dirs, + alpha, + beta, + }; + for dir in [&m.alpha, &m.beta] { + let (ok, _, err) = m.run(dir, &["init", "--json", "--module", "chat", "--no-service"]); + assert!(ok, "init: {err}"); + } + std::fs::create_dir_all(m.alpha.join(".nxs-personas")).unwrap(); + std::fs::write( + m.alpha.join(".nxs-personas/pm.yaml"), + "handle: pm\nsystem_prompt: You are the PM of test/alpha.\n", + ) + .unwrap(); + std::fs::create_dir_all(m.beta.join(".nxs-personas")).unwrap(); + std::fs::write( + m.beta.join(".nxs-personas/pm.yaml"), + "handle: pm\nsystem_prompt: You are the PM of test/beta.\naddressable:\n humans: true\n external:\n - \"*/pm\"\n", + ) + .unwrap(); + m +} + +#[test] +fn a_workspace_is_named_after_its_git_origin_and_the_name_is_stored() { + let m = machine(); + let (ok, out, _) = m.run(&m.alpha, &["name"]); + assert!(ok); + assert_eq!(out.trim(), "test/alpha"); + // The host is dropped and the case folded, whatever spelling the remote used. + let (_, out, _) = m.run(&m.beta, &["name"]); + assert_eq!(out.trim(), "test/beta"); + let config = std::fs::read_to_string(m.alpha.join(".nxs/config.toml")).unwrap(); + assert!( + config.contains("[workspace]") && config.contains("name = \"test/alpha\""), + "{config}" + ); + + // Set explicitly, it wins over the origin from then on. + let (ok, _, _) = m.run(&m.alpha, &["name", "acme/alpha"]); + assert!(ok); + let (_, out, _) = m.run(&m.alpha, &["name"]); + assert_eq!(out.trim(), "acme/alpha"); + let (ok, _, err) = m.run(&m.alpha, &["name", "NotAName"]); + assert!(!ok && err.contains("not a workspace name"), "{err}"); +} + +#[test] +fn a_neighbour_is_trusted_by_name_through_the_registry() { + let m = machine(); + let added = m.json( + &m.alpha, + &["--json", "sync", "trust", "add", "--workspace", "test/beta"], + ); + assert_eq!(added["workspace"], "test/beta", "{added}"); + assert_eq!(added["added"], true, "{added}"); + let listed = m.json(&m.alpha, &["--json", "sync", "trust", "list"]); + assert!( + listed.to_string().contains("test/beta"), + "the key is listed under the workspace's name: {listed}" + ); + let (ok, _, err) = m.run( + &m.alpha, + &["sync", "trust", "add", "--workspace", "test/gamma"], + ); + assert!( + !ok && err.contains("no workspace named test/gamma"), + "{err}" + ); +} + +#[test] +fn a_commission_crosses_to_the_other_workspace_and_its_coordinator_starts_the_persona_there() { + let m = machine(); + for (dir, other) in [(&m.alpha, "test/beta"), (&m.beta, "test/alpha")] { + let (ok, _, err) = m.run(dir, &["sync", "trust", "add", "--workspace", other]); + assert!(ok, "{err}"); + } + // The person commissions alpha's pm; the dry worker "starts" it and names its session. + let first = m.json( + &m.alpha, + &[ + "chat", + "--json", + "send", + "--to", + "pm", + "--no-ref", + "Ask test/beta.", + ], + ); + let session = first["session"].as_str().expect("a session").to_string(); + + // As that session, the pm commissions beta's pm by its address. + let out = m + .nxs(&m.alpha) + .env("NXC_SESSION", &session) + .args([ + "chat", + "--json", + "send", + "--to", + "test/beta/pm", + "--no-ref", + "When does it ship?", + ]) + .output() + .unwrap(); + let receipt: Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out.stderr))); + assert_eq!(receipt["border"]["peer"], "test/beta", "{receipt}"); + assert_eq!(receipt["border"]["state"], "working", "{receipt}"); + + // Beta's own handover — a child process in beta's directory — admitted it and started beta's pm. + let starts = m.starts(); + assert!( + starts.contains("role=pm") && starts.contains("coordinator=border"), + "beta's pm was started by its border coordinator: {starts}" + ); + + // Both workspaces show the thread with the other side and its state. + let thread = receipt["thread_id"].as_str().unwrap(); + for (dir, peer, direction) in [ + (&m.alpha, "test/beta", "outbound"), + (&m.beta, "test/alpha", "inbound"), + ] { + let status = m.json(dir, &["chat", "--json", "status", "--all"]); + let row = status["operations"] + .as_array() + .unwrap() + .iter() + .flat_map(|op| op["threads"].as_array().unwrap().iter()) + .find(|t| t["thread_id"] == thread) + .unwrap_or_else(|| panic!("{thread} in {}: {status}", dir.display())) + .clone(); + assert_eq!(row["border"]["peer"], peer, "{row}"); + assert_eq!(row["border"]["direction"], direction, "{row}"); + assert_eq!(row["border"]["state"], "working", "{row}"); + } + // A session answers in its own workspace: `--db` naming another one is refused, so a persona + // granted only `nxc reply` cannot write into a neighbour's log (nxf 6j6v.ewbj). + let beta_db = m.beta.join(".nxs/db.sqlite"); + let out = m + .nxs(&m.alpha) + .env("NXC_SESSION", &session) + .env("NXC_DB", m.alpha.join(".nxs/db.sqlite")) + .args(["chat", "reply", "--thread", thread, "--db"]) + .arg(&beta_db) + .arg("written elsewhere") + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !out.status.success() && stderr.contains("may not name another one"), + "{stderr}" + ); + + // And the service has something to carry while it is open. + assert!(m.alpha.join(".nxs/border-open").exists()); + assert!(m.beta.join(".nxs/border-open").exists()); +} + +/// **The scenario with real sessions** (nxf 6j6v.70dy, section 1 of the slice's specification) — +/// the PM of one repository asks the PM of another and passes the answer on, through the real +/// binary, the real sidecar and a real model. What the routing tests above cannot show: that a +/// persona actually commissions across the border, the other persona actually answers, and the +/// answer actually wakes the commissioner. +/// +/// `#[ignore]`d for the reasons every live smoke here is: real subscription auth, real seconds, +/// real cost. It does NOT pin `HOME` — the SDK's credentials live there — so the registry it +/// writes is a development instance of its own, `nexus-flow-border-live`, removed at the end. +/// Run it by hand where `claude` is authenticated: +/// +/// ```console +/// $ (cd agent-sidecar && npm ci) +/// $ cargo build -p nxs +/// $ cargo test -p nxs --test two_workspaces_on_one_machine -- --ignored --nocapture +/// ``` +#[test] +#[ignore = "live: real Claude Agent SDK sessions in two workspaces"] +fn live_the_pm_of_one_repository_asks_the_pm_of_another_and_tells_its_owner() { + const INSTANCE: &str = "nexus-flow-border-live"; + let repo = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("this crate sits at /crates/nxs") + .to_path_buf(); + let sidecar = repo.join("agent-sidecar/src/main.mjs"); + assert!(sidecar.is_file(), "no sidecar at {sidecar:?}"); + nxs_test_support::assert_multicall_binary_fresh(); + let bin = assert_cmd::cargo::cargo_bin("nxs"); + let bin_dir = bin.parent().unwrap().to_path_buf(); + // The instance's home, `~/.nexusflow-border-live` — named by the instance, so resolved by it. + let service_home = nxs_service::ServiceHome::for_instance( + nxs_service::Instance::named(INSTANCE).expect("a valid instance name"), + ) + .expect("a home directory") + .root() + .to_path_buf(); + assert!( + service_home.ends_with(".nexusflow-border-live"), + "{}", + service_home.display() + ); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(service_home); + + let dirs = TempDir::new().unwrap(); + let alpha = dirs.path().join("alpha"); + let beta = dirs.path().join("beta"); + git_repo(&alpha, "git@github.com:test/alpha.git"); + git_repo(&beta, "git@github.com:test/beta.git"); + let nxs = |dir: &Path| { + let mut c = Command::new(&bin); + c.current_dir(dir) + .env("NXS_SERVICE_INSTANCE", INSTANCE) + .env( + "PATH", + format!("{}:{}", bin_dir.display(), std::env::var("PATH").unwrap()), + ) + .env("NXC_WORKER", "sidecar") + .env("NXC_SIDECAR", &sidecar) + .env("NXC_ACTOR", "owner") + .env_remove("NXC_SESSION") + .env_remove("NXC_DB") + .env_remove("NXC_ORIGIN") + .env_remove("NXC_HOP") + .env_remove("NXC_NOW") + .env_remove("NXC_TIMER") + .env_remove("ANTHROPIC_API_KEY"); + c + }; + for dir in [&alpha, &beta] { + let ok = nxs(dir) + .args(["init", "--json", "--module", "chat", "--no-service"]) + .status() + .unwrap() + .success(); + assert!(ok); + std::fs::create_dir_all(dir.join(".nxs-personas")).unwrap(); + } + std::fs::write( + alpha.join(".nxs-personas/pm.yaml"), + "handle: pm\nsystem_prompt: |\n You are the PM of test/alpha. Keep every message short.\n \ + When the person asks you to find something out from the PM of another repository, run\n \ + exactly `nxc send --to //pm --no-ref \"\"` and end your turn without\n \ + replying. When you are woken with the answer, pass it on to the person with `nxc reply`.\n", + ) + .unwrap(); + std::fs::write( + beta.join(".nxs-personas/pm.yaml"), + "handle: pm\naddressable:\n humans: true\n external:\n - \"*/pm\"\nsystem_prompt: |\n \ + You are the PM of test/beta. Keep every message short. Before answering, look at your\n \ + team with `nxc list`. Your board says: the access rule on channels ships with version\n \ + 0.300. Answer what you are asked with `nxc reply`, in one sentence.\n", + ) + .unwrap(); + for (dir, other) in [(&alpha, "test/beta"), (&beta, "test/alpha")] { + let ok = nxs(dir) + .args(["sync", "trust", "add", "--workspace", other]) + .status() + .unwrap() + .success(); + assert!(ok); + } + + let out = nxs(&alpha) + .args([ + "chat", + "--json", + "send", + "--to", + "pm", + "--no-ref", + "Ask the PM of test/beta when the access rule on channels ships, then tell me.", + ]) + .output() + .unwrap(); + let receipt: Value = serde_json::from_slice(&out.stdout).unwrap(); + let thread = receipt["thread_id"].as_str().unwrap().to_string(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(400); + let answer = loop { + let out = nxs(&alpha) + .args(["chat", "--json", "threads", "show", &thread]) + .output() + .unwrap(); + let board: Value = serde_json::from_slice(&out.stdout).unwrap_or(Value::Null); + if let Some(body) = board["messages"].as_array().and_then(|ms| { + ms.iter() + .find(|m| m["sender"].as_str().is_some_and(|s| s.ends_with("/pm"))) + .and_then(|m| m["body"].as_str()) + }) { + break body.to_string(); + } + assert!( + std::time::Instant::now() < deadline, + "alpha's pm never answered its owner: {board:#}" + ); + std::thread::sleep(std::time::Duration::from_secs(5)); + }; + assert!( + !answer.starts_with("sidecar:"), + "the persona answered itself: {answer}" + ); + assert!( + answer.contains("0.300"), + "the answer came from beta's board: {answer}" + ); +} diff --git a/docs/specs/E5c-chat-orchestration-api.md b/docs/specs/E5c-chat-orchestration-api.md index c8cd7ef..c3a02d2 100644 --- a/docs/specs/E5c-chat-orchestration-api.md +++ b/docs/specs/E5c-chat-orchestration-api.md @@ -129,12 +129,17 @@ E5c closes all three: 3. **Plumb per-role/step/call model selection** (`fable`|`opus`|`sonnet`), which exists nowhere in the spawn path today — a definition naming a model is inert until it does. -**Hard scope guardrail (owner, 2026-07-30, recorded as a note on `qvfp`).** This seam delivers -**exclusively** the single-repo role runtime — roles, channels, workflows, quorum and app-supplied -definitions **within one** workspace, plus transcript read. **No** cross-repo routing, **no** -cross-workspace bridge, **no** repo-spanning WAIT resolution. Cross-repo communication is the -manufakt.io USP and stays there (`dqem.c5em`). This boundary is not negotiable and not "just for -now": what becomes surface here goes irreversibly public with the open-source launch. +**Scope guardrail (owner, 2026-07-30, recorded as a note on `qvfp`) — and its cross-repo clause +LIFTED by the owner on 2026-10-03 (epic `6j6v.70dy`).** This seam was cut as the single-repo role +runtime — roles, channels, workflows, quorum and app-supplied definitions within one workspace, plus +transcript read. The 2026-07-30 note excluded cross-repo routing and any cross-workspace bridge as +the manufakt.io USP (`dqem.c5em`). That ruling is superseded: conversation across repository and +application borders belongs in the open nexus-flow (owner direction of 2026-10-03, decisions E1–E9 +in the coordinator workspace's `2026-10-03-standards-und-foederation-richtung.md`). The first slice +is on this seam now — a persona commissions a persona of another workspace on the same machine +(`crate::border`, `Engine::handover`, `addressable.external`, `EngineConfig::peers`), and only the +border thread crosses. Still NOT here: repo-spanning WAIT resolution, anything over a network, and +a person addressing a foreign persona directly. ## 2. Why the verbs cannot be lifted as they stand @@ -1122,7 +1127,9 @@ Beyond parity, the acceptance is behavioural and has to be run for real, not ass ## 6. Explicitly not in scope -- **Cross-repo routing, cross-workspace bridging, repo-spanning WAIT resolution** — §1, non-negotiable. +- **Repo-spanning WAIT resolution, and any cross-workspace path over a network** — §1. Cross-repo + routing between two workspaces on one machine is no longer excluded: the owner lifted that clause + on 2026-10-03 (epic `6j6v.70dy`), and its first slice is `crate::border`. - **Transcript authorization** (`yxsa`). The transcript read — `Engine::transcript_page` since nxf 6j6v.yr59 — remains ungated and keeps its existing "an app serving more than one user must authorize the caller itself" warning. Adjacent, watched, not fixed here.