pi-onlyne 0.8.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ // Protocol vocabulary tests. Every assertion about a frame's shape is anchored
2
+ // either on a wire vector the Rust encoder produced or on the reducer rules in
3
+ // `crates/onlyne-session/src/lifecycle.rs`.
4
+
5
+ import assert from "node:assert/strict";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { test } from "node:test";
9
+
10
+ import {
11
+ IMAGE_DATA_MAX_BYTES,
12
+ MAX_HEAD_CHARS,
13
+ PROTOCOL_VERSION,
14
+ SEQ_BASE,
15
+ completeReport,
16
+ headOf,
17
+ heartbeatReport,
18
+ helloArgs,
19
+ hostBinding,
20
+ imagePart,
21
+ injectionText,
22
+ normalizeOutcome,
23
+ observationFor,
24
+ readPluginVersion,
25
+ readyReport,
26
+ sendEnvelope,
27
+ stdinTaskText,
28
+ welcomeFrom,
29
+ } from "./protocol.mjs";
30
+
31
+ const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
32
+ const hasVectors = existsSync(VECTOR_DIR);
33
+ const vectorFrame = (name) => JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}${name}`, "utf8")).frame);
34
+ const ASSIGN = () => vectorFrame("adapter_host_assign.json");
35
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
36
+
37
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
38
+
39
+ test("hello carries the package version and a flat agent mount", () => {
40
+ assert.equal(readPluginVersion(), packageJson.version);
41
+ const args = helloArgs({
42
+ role: "planner",
43
+ session: "8b1c",
44
+ taskId: "11111111-1111-4111-8111-111111111111",
45
+ pid: 4212,
46
+ capabilities: ["register", "report", "inject", "recycle"],
47
+ });
48
+ assert.deepEqual(args, {
49
+ protocol: PROTOCOL_VERSION,
50
+ plugin: "pi-onlyne",
51
+ version: packageJson.version,
52
+ kind: "agent",
53
+ capabilities: ["register", "report", "inject", "recycle"],
54
+ mount: {
55
+ role: "planner",
56
+ session: "8b1c",
57
+ task_id: "11111111-1111-4111-8111-111111111111",
58
+ pid: 4212,
59
+ },
60
+ });
61
+ });
62
+
63
+ test("hello names exactly the keys the host's own vector names", { skip: !hasVectors }, () => {
64
+ const reference = vectorFrame("adapter_plugin_hello_plan_example.json");
65
+ const ours = { op: "hello", args: helloArgs({ role: "planner", session: "8b1c", capabilities: ["register"] }) };
66
+ assert.deepEqual(Object.keys(ours.args).sort(), Object.keys(reference.args).sort());
67
+ assert.deepEqual(Object.keys(ours.args.mount).sort(), ["role", "session"]);
68
+ assert.equal(reference.args.mount.role, ours.args.mount.role);
69
+ assert.equal(ours.args.kind, reference.args.kind);
70
+ assert.equal(ours.args.protocol, reference.args.protocol);
71
+ });
72
+
73
+ test("welcome parses the host's response body and rejects junk", () => {
74
+ const args = {
75
+ protocol: 1,
76
+ role: "planner",
77
+ session_id: "s1",
78
+ generation: 1,
79
+ prose: "Read the incoming task",
80
+ server: { connected: true, cluster: "local", name: "server" },
81
+ host_capabilities: ["probe", "recycle"],
82
+ };
83
+ const welcome = welcomeFrom({ op: "welcome", args });
84
+ assert.deepEqual(welcome, {
85
+ protocol: 1,
86
+ role: "planner",
87
+ sessionId: "s1",
88
+ generation: 1,
89
+ prose: "Read the incoming task",
90
+ server: { connected: true, cluster: "local", name: "server" },
91
+ hostCapabilities: ["probe", "recycle"],
92
+ });
93
+ assert.deepEqual(welcomeFrom(args)?.role, "planner");
94
+ assert.equal(welcomeFrom(null), null);
95
+ assert.equal(welcomeFrom({ op: "welcome", args: { prose: "x" } }), null);
96
+ assert.deepEqual(welcomeFrom({ op: "welcome", args: { role: "builder" } }), {
97
+ protocol: 1,
98
+ role: "builder",
99
+ sessionId: null,
100
+ generation: 1,
101
+ prose: "",
102
+ server: null,
103
+ hostCapabilities: [],
104
+ });
105
+ });
106
+
107
+ test("ready matches the host's ready vector, which relays a cluster this plugin never speaks for", { skip: !hasVectors }, () => {
108
+ const reference = vectorFrame("adapter_plugin_report.json");
109
+ assert.equal(reference.op, "report");
110
+ assert.equal(reference.args.kind, "ready");
111
+ const { cluster_ref: relayed, ...local } = reference.args.data;
112
+ assert.equal(relayed, "cluster-b");
113
+ const ours = readyReport({ taskId: local.task_id, sessionId: local.session_id, generation: local.generation, seq: local.seq });
114
+ assert.deepEqual(ours, { kind: "ready", data: local });
115
+ });
116
+ test("heartbeat carries a full legal observation", () => {
117
+ const report = heartbeatReport({ taskId: "t1", generation: 2, seq: SEQ_BASE + 7, agent: "running" });
118
+ assert.equal(report.kind, "heartbeat");
119
+ assert.equal(report.data.task_id, "t1");
120
+ assert.equal(report.data.seq, SEQ_BASE + 7);
121
+ assert.deepEqual(report.data.observed, {
122
+ version: { generation: 2, seq: SEQ_BASE + 7 },
123
+ generation_live: true,
124
+ isolate_after: 1,
125
+ terminate_after: 3,
126
+ mismatch_count: 0,
127
+ agent: "running",
128
+ delivery: "none",
129
+ resource: "attached",
130
+ recovery: "none",
131
+ outcome: "pending",
132
+ public: "working",
133
+ });
134
+ });
135
+
136
+ test("every observation is a state tuple the reducer calls legal", () => {
137
+ // Mirrors onlyne-session's `project`: running works, idle/ready wait, booting creates.
138
+ const expected = { booting: "created", ready: "idle", running: "working", idle: "idle", gone: "created" };
139
+ for (const [agent, projected] of Object.entries(expected)) {
140
+ const observed = observationFor(agent, { generation: 1, seq: 1 });
141
+ assert.equal(observed.public, projected, `agent=${agent}`);
142
+ assert.notEqual(observed.isolate_after, 0);
143
+ assert.notEqual(observed.terminate_after, 0);
144
+ assert.equal(observed.outcome, "pending");
145
+ assert.equal(observed.delivery, "none");
146
+ assert.equal(observed.recovery, "none");
147
+ }
148
+ });
149
+
150
+ const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
151
+ const PANE_ENV = {
152
+ ORCA_PANE_KEY: PANE_KEY,
153
+ ORCA_TAB_ID: "45e603f7-0772-48aa-bcf6-832272747713",
154
+ ORCA_LEAF_ID: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
155
+ ORCA_TERMINAL_HANDLE: "term_1",
156
+ };
157
+
158
+ test("hostBinding names the Orca pane the environment exports", () => {
159
+ // The shape is `onlyne-session`'s `HostRef`: `{"orca":{…}}`, so the Rust side
160
+ // deserialises the observation without a second translation.
161
+ assert.deepEqual(hostBinding(PANE_ENV), {
162
+ orca: {
163
+ pane_key: PANE_KEY,
164
+ tab_id: "45e603f7-0772-48aa-bcf6-832272747713",
165
+ leaf_id: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
166
+ handle: "term_1",
167
+ },
168
+ });
169
+ });
170
+
171
+ test("hostBinding reads the ids out of the pane key and invents nothing", () => {
172
+ // The pane key is `<tab_id>:<leaf_id>`; the handle is a separate export, and
173
+ // a missing one is absent rather than empty.
174
+ assert.deepEqual(hostBinding({ ORCA_PANE_KEY: PANE_KEY }), {
175
+ orca: {
176
+ pane_key: PANE_KEY,
177
+ tab_id: "45e603f7-0772-48aa-bcf6-832272747713",
178
+ leaf_id: "b6d067b6-9255-4f5c-a13f-24f194ea0560",
179
+ },
180
+ });
181
+ // An explicit id outranks the key's own spelling of it.
182
+ const explicit = hostBinding({ ...PANE_ENV, ORCA_TAB_ID: "aaaa1111-1111-4111-8111-111111111111" });
183
+ assert.equal(explicit.orca.tab_id, "aaaa1111-1111-4111-8111-111111111111");
184
+ assert.equal(explicit.orca.pane_key, "aaaa1111-1111-4111-8111-111111111111:b6d067b6-9255-4f5c-a13f-24f194ea0560");
185
+ });
186
+
187
+ test("hostBinding is null when the environment names no pane", () => {
188
+ assert.equal(hostBinding({}), null, "a plain shell is not in a pane");
189
+ assert.equal(hostBinding(undefined), null);
190
+ // A pane key without a leaf names no pane, and one id alone is not a pane.
191
+ assert.equal(hostBinding({ ORCA_PANE_KEY: "not-a-pane-key" }), null);
192
+ assert.equal(hostBinding({ ORCA_TAB_ID: "tab-only" }), null);
193
+ });
194
+
195
+ test("observationFor attaches the host only when there is one", () => {
196
+ const bare = observationFor("running", { generation: 1, seq: 2 });
197
+ assert.equal("host" in bare, false, "a tuple without a pane carries no host key");
198
+
199
+ const hosted = observationFor("running", { generation: 1, seq: 2, host: hostBinding(PANE_ENV) });
200
+ assert.deepEqual(hosted.host, hostBinding(PANE_ENV));
201
+ // The binding rides beside the dimensions: the tuple is still the same state.
202
+ const { host, ...dimensions } = hosted;
203
+ assert.deepEqual(dimensions, bare);
204
+ });
205
+
206
+ test("heartbeatReport carries the host inside observed", () => {
207
+ const report = heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 1, agent: "idle", host: hostBinding(PANE_ENV) });
208
+ assert.equal(report.data.observed.host.orca.pane_key, PANE_KEY);
209
+ assert.equal(report.data.observed.agent, "idle");
210
+ assert.equal(
211
+ "host" in heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 2, agent: "idle" }).data.observed,
212
+ false
213
+ );
214
+ });
215
+
216
+ test("a completion names an outcome and a single-line head", () => {
217
+ assert.deepEqual(completeReport({ taskId: "t1", outcome: "failed", head: "broke\non line two" }), {
218
+ kind: "complete",
219
+ data: { task_id: "t1", outcome: "failed", head: "broke on line two" },
220
+ });
221
+ // No head key at all when there is nothing to summarise.
222
+ assert.deepEqual(completeReport({ taskId: "t1", outcome: "done", head: " " }), {
223
+ kind: "complete",
224
+ data: { task_id: "t1", outcome: "done" },
225
+ });
226
+ assert.equal(completeReport({ taskId: "t1", outcome: "weird" }).data.outcome, "done");
227
+ assert.equal(normalizeOutcome(undefined), "done");
228
+ assert.equal(normalizeOutcome("cancelled"), "cancelled");
229
+ });
230
+
231
+ test("the ledger head is capped at the plan's 200 characters", () => {
232
+ assert.equal(MAX_HEAD_CHARS, 200);
233
+ assert.equal(headOf("x".repeat(500)).length, 200);
234
+ assert.equal(headOf(" spaced out \n text "), "spaced out text");
235
+ assert.equal(headOf(undefined), "");
236
+ });
237
+
238
+ test("an assignment becomes one message naming its origin and payload", { skip: !hasVectors }, () => {
239
+ const assign = ASSIGN().args;
240
+ const text = injectionText({ assign, proseIsNew: true });
241
+ assert.match(text, /^\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
242
+ assert.match(text, /\[onlyne\] role prose from the spec:\nRead the incoming task/);
243
+ assert.match(text, /\nbuild it\n?$/);
244
+
245
+ const repeat = injectionText({ assign, proseIsNew: false });
246
+ assert.doesNotMatch(repeat, /role prose from the spec/);
247
+ assert.match(repeat, /build it/);
248
+ });
249
+
250
+ test("an empty-bodied assignment still produces an instruction", () => {
251
+ const text = injectionText({
252
+ assign: { task_id: "t9", envelope: { id: "e9", kind: "note", from: { gateway: { gateway: "fg1", channel: "fake", conversation: "c1" } }, body: {} } },
253
+ proseIsNew: false,
254
+ attachmentPaths: ["/ws/.onlyne/tmp/attachments/a.png"],
255
+ });
256
+ assert.match(text, /from gateway:fg1:fake:c1/);
257
+ assert.match(text, /the task carried no text/);
258
+ assert.match(text, /\/ws\/\.onlyne\/tmp\/attachments\/a\.png/);
259
+ });
260
+
261
+ test("a note carries no idempotency key and a task carries both", { skip: !hasVectors }, () => {
262
+ const note = sendEnvelope({ from: "planner", to: "builder", kind: "note", text: "ping" });
263
+ assert.deepEqual(Object.keys(note).sort(), ["admin", "body", "from", "id", "kind", "protocol", "to", "ts"].sort());
264
+ assert.deepEqual(
265
+ Object.keys(note).sort(),
266
+ Object.keys(vectorFrame("adapter_plugin_send_note.json").args).sort(),
267
+ );
268
+ assert.match(note.id, UUID_RE);
269
+ assert.deepEqual(note.from, { role: { role: "planner" } });
270
+
271
+ const task = sendEnvelope({ from: "planner", to: "builder", kind: "task", text: "build it" });
272
+ assert.match(task.op_id, /^o-[0-9a-f-]{36}$/);
273
+ assert.match(task.causality.task, UUID_RE);
274
+ assert.equal(task.causality.hop, 0);
275
+ assert.equal(task.causality.attempt, 0);
276
+ assert.throws(() => sendEnvelope({ from: "planner", to: "builder" }), /body requires text or image/);
277
+ });
278
+
279
+ test("images are accepted only in the four core mimes and under the byte ceiling", () => {
280
+ const part = imagePart({ data: Buffer.from([1, 2, 3]), mime: "image/png", name: "shot.png" });
281
+ assert.equal(part.data_base64, Buffer.from([1, 2, 3]).toString("base64"));
282
+ assert.equal(part.mime, "image/png");
283
+ assert.equal(part.name, "shot.png");
284
+ assert.throws(() => imagePart({ data: Buffer.from([1]), mime: "image/svg+xml" }), /unsupported/);
285
+ assert.throws(
286
+ () => imagePart({ data: Buffer.alloc(IMAGE_DATA_MAX_BYTES + 1), mime: "image/png" }),
287
+ /exceeds/,
288
+ );
289
+ });
290
+
291
+ test("the stdin route recognises only the config_get overload", () => {
292
+ assert.deepEqual(stdinTaskText({ key: "stdin:do the thing" }), { text: "do the thing" });
293
+ assert.equal(stdinTaskText({ key: "model.name" }), null);
294
+ assert.equal(stdinTaskText({}), null);
295
+ assert.equal(stdinTaskText(undefined), null);
296
+ });
package/src/relay.mjs ADDED
@@ -0,0 +1,299 @@
1
+ // The relay guard's policy: `<plugin package dir>/relay.toml`.
2
+ //
3
+ // The guard exists because a session narrated work in progress and then
4
+ // reported `done` with its todos untouched, leaving the downstream writer
5
+ // waiting on a handoff that never happened. The policy below is the minimum a
6
+ // session owes downstream before `onlyne_complete` may end it, expressed in
7
+ // delivery facts only: which roles this session handed something to, never
8
+ // what the text said (that is the critic layer's business, not the adapter's).
9
+ //
10
+ // The file sits next to `package.json`, so the policy travels with the plugin
11
+ // copy a generated workspace carries: `onlyne server generate` copies the
12
+ // package to `<ws>/.onlyne/agent/<pkg-name>/` and `.pi/settings.json` loads
13
+ // that copy (`crates/onlyne-server/src/generate.rs`). It is deliberately not
14
+ // `<ws>/.onlyne/config.toml`: the client parses that file as `ClientConfig`,
15
+ // which is `#[serde(deny_unknown_fields)]` and `additionalProperties: false`
16
+ // (`crates/onlyne-config/src/client.rs`, `schema/config-client.schema.json`),
17
+ // so a plugin-owned key there would make the client refuse to start. A `[local]`
18
+ // fragment merged into it has the same problem.
19
+ //
20
+ // The accepted body is a closed subset of TOML — flat `key = value` lines, the
21
+ // two keys below, one-line arrays of double-quoted strings — because this
22
+ // package parses its own files by hand and the runtime has no npm dependencies.
23
+ // Anything outside the subset is reported on stderr and ignored, the same
24
+ // degrade-don't-disable way `.pi/onlyne.json` behaves. A missing file is the
25
+ // default, which is "no guard": absent policy means the plugin behaves exactly
26
+ // as it did before this module existed.
27
+ //
28
+ // relay_required = ["writer"] # these roles must have received a handoff
29
+ // relay_required_count = 2 # ... or this many distinct downstream roles
30
+ //
31
+ // `relay_required` wins when both are present.
32
+ //
33
+ // The file is the manual installation's escape hatch. A generated workspace
34
+ // carries the same policy in its spec, and the client injects it into every
35
+ // session process it spawns, so the environment comes first:
36
+ //
37
+ // ONLYNE_RELAY_REQUIRED=writer,auditor # the spec's `relay_required`
38
+ // ONLYNE_RELAY_COUNT=2 # the spec's `relay_count`
39
+ //
40
+ // A variable that is set and unparsable is reported on stderr and ignored, and
41
+ // with nothing usable in the environment the file is read as before.
42
+
43
+ import { readFileSync } from "node:fs";
44
+ import { dirname, join } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ /** File name, resolved next to the plugin's `package.json`. */
48
+ export const RELAY_FILE = "relay.toml";
49
+
50
+ /** Fixed marker a waived completion's ledger head starts with. */
51
+ export const FORCED_PREFIX = "relay-guard-forced: ";
52
+
53
+ /** No policy: an empty list and no count, both frozen together. */
54
+ export const DEFAULT_RELAY = Object.freeze({ required: Object.freeze([]), count: null });
55
+
56
+ /** The client's injected policy variables, filled from the spec's entry. */
57
+ export const RELAY_ENV_REQUIRED = "ONLYNE_RELAY_REQUIRED";
58
+ export const RELAY_ENV_COUNT = "ONLYNE_RELAY_COUNT";
59
+
60
+ /**
61
+ * The policy file this module reads by default: beside `package.json`, the way
62
+ * `protocol.mjs` reads the plugin version.
63
+ */
64
+ export function relayPath() {
65
+ return join(dirname(fileURLToPath(import.meta.url)), "..", RELAY_FILE);
66
+ }
67
+
68
+ /**
69
+ * Whether a loaded policy guards anything: a non-empty list, or a positive
70
+ * count. A malformed key leaves its own dimension off, so one bad line cannot
71
+ * silently arm the guard with the wrong rule.
72
+ *
73
+ * @param {{ required?: string[], count?: number | null } | null | undefined} config
74
+ */
75
+ export function relayEnabled(config) {
76
+ if (!config) return false;
77
+ if (Array.isArray(config.required) && config.required.length > 0) return true;
78
+ return Number.isInteger(config.count) && config.count > 0;
79
+ }
80
+
81
+ /**
82
+ * Parse one `relay.toml` body.
83
+ *
84
+ * @param {string} text
85
+ * @param {string} [name] file name for diagnostics
86
+ * @returns {{ required: string[], count: number | null, warning: string | null }}
87
+ */
88
+ export function parseRelay(text, name = RELAY_FILE) {
89
+ const config = { required: [], count: null };
90
+ const warnings = [];
91
+ const warn = (line, detail) => warnings.push(`${name}:${line}: ${detail}`);
92
+
93
+ String(text)
94
+ .split(/\r?\n/)
95
+ .forEach((raw, index) => {
96
+ const line = index + 1;
97
+ const body = stripComment(raw).trim();
98
+ if (!body) return;
99
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\S.*)$/.exec(body);
100
+ if (!assignment) {
101
+ warn(line, `not a \`key = value\` line (${JSON.stringify(raw.trim())}); ignored`);
102
+ return;
103
+ }
104
+ const [, key, value] = assignment;
105
+ if (key === "relay_required") {
106
+ const list = parseStringArray(value);
107
+ if (list === null) {
108
+ warn(line, 'relay_required must be one line of double-quoted names, e.g. ["writer"]; ignored');
109
+ return;
110
+ }
111
+ config.required = list;
112
+ return;
113
+ }
114
+ if (key === "relay_required_count") {
115
+ const count = /^[0-9]+$/.test(value) ? Number(value) : 0;
116
+ if (count < 1) {
117
+ warn(line, "relay_required_count must be a positive integer; ignored");
118
+ return;
119
+ }
120
+ config.count = count;
121
+ return;
122
+ }
123
+ warn(line, `unknown key ${JSON.stringify(key)}; ignored`);
124
+ });
125
+
126
+ return { ...config, warning: warnings.length > 0 ? warnings.join("; ") : null };
127
+ }
128
+
129
+ /**
130
+ * The policy the client injected from the spec, when it injected one.
131
+ *
132
+ * The list is one comma-joined variable, in the order the spec wrote it; blank
133
+ * entries are dropped, so a stray comma is not a role name. A variable that is
134
+ * set but unparsable is reported and ignored rather than adopted, which keeps a
135
+ * typo from arming the guard with a rule nobody wrote — and `specified` then
136
+ * says the environment supplied nothing, so the file still gets its turn.
137
+ *
138
+ * @param {Record<string, string | undefined>} [env]
139
+ * @returns {{ required: string[], count: number | null, specified: boolean, warning: string | null }}
140
+ */
141
+ export function envRelay(env = process.env) {
142
+ const warnings = [];
143
+ let required = null;
144
+ let count = null;
145
+
146
+ const rawRequired = env[RELAY_ENV_REQUIRED];
147
+ if (rawRequired !== undefined) {
148
+ const names = String(rawRequired)
149
+ .split(",")
150
+ .map((name) => name.trim())
151
+ .filter(Boolean);
152
+ if (names.length > 0) required = names;
153
+ else warnings.push(`${RELAY_ENV_REQUIRED}: no role names in ${JSON.stringify(rawRequired)}; ignored`);
154
+ }
155
+
156
+ const rawCount = env[RELAY_ENV_COUNT];
157
+ if (rawCount !== undefined) {
158
+ const text = String(rawCount).trim();
159
+ const parsed = /^[0-9]+$/.test(text) ? Number(text) : 0;
160
+ if (parsed > 0) count = parsed;
161
+ else warnings.push(`${RELAY_ENV_COUNT} must be a positive integer, got ${JSON.stringify(rawCount)}; ignored`);
162
+ }
163
+
164
+ return {
165
+ required: required ?? [],
166
+ count,
167
+ specified: required !== null || count !== null,
168
+ warning: warnings.length > 0 ? warnings.join("; ") : null,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Read the policy: what the client injected from the spec first, then the file
174
+ * beside `package.json`.
175
+ *
176
+ * `source` names the winner, and `present` answers the narrower question the
177
+ * file itself raises: the environment winning means the file was never read, so
178
+ * a stale `relay.toml` cannot outlive the spec entry that replaced it.
179
+ *
180
+ * @param {{ readFile?: (path: string) => string, path?: string, env?: Record<string, string | undefined> }} [options]
181
+ * @returns {{ required: string[], count: number | null, path: string, present: boolean, source: "env" | "file" | "none", warning: string | null }}
182
+ */
183
+ export function loadRelay(options = {}) {
184
+ const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
185
+ const path = options.path ?? relayPath();
186
+ const injected = envRelay(options.env ?? process.env);
187
+ if (injected.specified) {
188
+ return {
189
+ required: injected.required,
190
+ count: injected.count,
191
+ path,
192
+ present: false,
193
+ source: "env",
194
+ warning: injected.warning,
195
+ };
196
+ }
197
+ let raw;
198
+ try {
199
+ raw = readFile(path);
200
+ } catch {
201
+ return {
202
+ ...DEFAULT_RELAY,
203
+ path,
204
+ present: false,
205
+ source: "none",
206
+ warning: injected.warning,
207
+ };
208
+ }
209
+ const parsed = parseRelay(raw, path);
210
+ return {
211
+ required: parsed.required,
212
+ count: parsed.count,
213
+ path,
214
+ present: true,
215
+ source: "file",
216
+ warning: [injected.warning, parsed.warning].filter(Boolean).join("; ") || null,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The verdict for one `onlyne_complete`, as a refusal message or `null`.
222
+ *
223
+ * `delivered` is the set of roles this session's own successful `onlyne_send`
224
+ * calls reached. List mode is literal: every named role must be in it. Count
225
+ * mode counts distinct downstream roles, so a send to this role itself and a
226
+ * send back to the role that assigned the task (the upstream) do not count —
227
+ * neither of them hands work further down the cluster.
228
+ *
229
+ * @param {{ required?: string[], count?: number | null } | null} config
230
+ * @param {Iterable<string>} delivered
231
+ * @param {{ role?: string | null, upstream?: string | null }} [context]
232
+ * @returns {string | null}
233
+ */
234
+ export function relayRefusal(config, delivered, context = {}) {
235
+ if (!relayEnabled(config)) return null;
236
+ const sent = new Set([...delivered].map((name) => String(name)));
237
+
238
+ if (Array.isArray(config.required) && config.required.length > 0) {
239
+ const missing = config.required.filter((name) => !sent.has(name));
240
+ if (missing.length === 0) return null;
241
+ return refusalText(
242
+ `missing handoff to: ${missing.join(", ")}`,
243
+ `this session delivered to: ${listOf([...sent])}`,
244
+ );
245
+ }
246
+
247
+ const { role = null, upstream = null } = context;
248
+ const downstream = [...sent].filter((name) => name !== role && name !== upstream);
249
+ if (downstream.length >= config.count) return null;
250
+ return refusalText(
251
+ `missing handoff: ${config.count - downstream.length} of ${config.count} required distinct downstream roles`,
252
+ `delivered downstream: ${listOf(downstream)}`,
253
+ );
254
+ }
255
+
256
+ /** The one refusal sentence: what is missing, what exists, and the way out. */
257
+ function refusalText(shortfall, evidence) {
258
+ return (
259
+ `relay guard: ${shortfall} (${evidence}); ` +
260
+ "send the missing edge with onlyne_send, then call onlyne_complete again — or call it with " +
261
+ 'force:true and a non-empty reason to waive the guard and stamp the ledger head with ' +
262
+ `"${FORCED_PREFIX}<reason>"`
263
+ );
264
+ }
265
+
266
+ /** A comma-joined list, or `none` when there is nothing to name. */
267
+ function listOf(names) {
268
+ return names.length > 0 ? names.join(", ") : "none";
269
+ }
270
+
271
+ /** Everything before an unquoted `#`; the subset has no multi-line strings. */
272
+ function stripComment(line) {
273
+ let quoted = false;
274
+ for (let index = 0; index < line.length; index += 1) {
275
+ const char = line[index];
276
+ if (char === '"' && line[index - 1] !== "\\") quoted = !quoted;
277
+ else if (char === "#" && !quoted) return line.slice(0, index);
278
+ }
279
+ return line;
280
+ }
281
+
282
+ /** One line of double-quoted strings, or `null` for anything else. */
283
+ function parseStringArray(body) {
284
+ const trimmed = body.trim();
285
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
286
+ const items = [];
287
+ let rest = trimmed.slice(1, -1).trim();
288
+ if (rest === "") return items;
289
+ for (;;) {
290
+ const item = /^"((?:[^"\\]|\\.)*)"\s*/.exec(rest);
291
+ if (!item) return null;
292
+ items.push(item[1].replace(/\\(.)/g, "$1"));
293
+ rest = rest.slice(item[0].length).trimStart();
294
+ if (rest === "") return items;
295
+ if (!rest.startsWith(",")) return null;
296
+ rest = rest.slice(1).trimStart();
297
+ if (rest === "") return null; // a trailing comma is not TOML
298
+ }
299
+ }