@cohortapp/agent-sdk 2.3.1 → 2.4.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.
- package/bin/maestro.mjs +37 -50
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +9 -4
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
package/lib/org/client.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
okFrame,
|
|
39
39
|
errFrame,
|
|
40
40
|
} from "./protocol.mjs";
|
|
41
|
+
import { normalizeParams, logReportOnce, toRegisterParams } from "./param-contract.mjs";
|
|
41
42
|
import { applyBrandEnvCompat } from "../env-compat.mjs";
|
|
42
43
|
|
|
43
44
|
// SDK self-bridge: agent instances import this module directly from their own
|
|
@@ -63,13 +64,31 @@ const DEFAULT_TIMEOUT_MS = 8000;
|
|
|
63
64
|
* @returns {object}
|
|
64
65
|
*/
|
|
65
66
|
export function loadOrgConfig(agentRoot) {
|
|
67
|
+
const p = join(agentRoot, "config", "org.yaml");
|
|
68
|
+
// Genuinely absent is not a fault: an un-enrolled agent has no org.yaml.
|
|
69
|
+
if (!existsSync(p)) return {};
|
|
66
70
|
try {
|
|
67
|
-
const p = join(agentRoot, "config", "org.yaml");
|
|
68
|
-
if (!existsSync(p)) return {};
|
|
69
71
|
const require = createRequire(import.meta.url);
|
|
70
72
|
const yaml = require("js-yaml");
|
|
71
73
|
return yaml.load(readFileSync(p, "utf8")) || {};
|
|
72
|
-
} catch {
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// The file EXISTS but could not be read/parsed. Returning {} silently here
|
|
76
|
+
// is how an enrolled agent ends up quietly un-enrolled: `isEnabled({})` is
|
|
77
|
+
// false, so the org mesh no-ops, the daemon logs nothing, and the agent
|
|
78
|
+
// never beats or receives the kill switch. Observed in the wild — the
|
|
79
|
+
// scaffold's package.json omitted `js-yaml`, so this require() threw on
|
|
80
|
+
// every freshly-created agent and the org integration was simply off.
|
|
81
|
+
//
|
|
82
|
+
// Still fail-open (callers depend on never throwing), but never silent.
|
|
83
|
+
const why = /Cannot find module 'js-yaml'/.test(String(err && err.message))
|
|
84
|
+
? "js-yaml is not installed — run `npm install` (it is a required runtime dep)"
|
|
85
|
+
: String(err && err.message);
|
|
86
|
+
try {
|
|
87
|
+
console.warn(
|
|
88
|
+
`[org] config/org.yaml exists but could not be loaded (${why}). ` +
|
|
89
|
+
`Treating this agent as NOT enrolled — it will not join the org mesh.`,
|
|
90
|
+
);
|
|
91
|
+
} catch { /* never throw from logging */ }
|
|
73
92
|
return {};
|
|
74
93
|
}
|
|
75
94
|
}
|
|
@@ -250,9 +269,18 @@ export async function pairRequest(o = {}) {
|
|
|
250
269
|
* posts the params object, and returns the parsed res frame. Fail-open: a
|
|
251
270
|
* transport error yields an INTERNAL error frame rather than throwing.
|
|
252
271
|
*
|
|
272
|
+
* THE WIRE-CONTRACT CHOKEPOINT. Every plane reaches hq through this one function
|
|
273
|
+
* — the 2 090 `ui-parity.mjs` wrappers, the curated `tool-surface.mjs` table, the
|
|
274
|
+
* `org_rpc` escape hatch, and every hand-written helper in `lib/org/*.mjs` — so
|
|
275
|
+
* `normalizeParams` (lib/org/param-contract.mjs) runs HERE. That is what makes
|
|
276
|
+
* param-name drift a class of bug this SDK cannot ship again: a call site cannot
|
|
277
|
+
* opt out, and a newly added wrapper inherits the contract for free. The rewrite
|
|
278
|
+
* is reported once per distinct (method, rewrite) per process — fail-open, but
|
|
279
|
+
* NEVER silent (silent fail-open is what hid all 18 drift rows).
|
|
280
|
+
*
|
|
253
281
|
* @param {string} method full dotted method name (e.g. "board.claim")
|
|
254
282
|
* @param {object} params the params object (request body)
|
|
255
|
-
* @param {object} o - { base, token, orgId?, idempotencyKey?, fetchImpl? }
|
|
283
|
+
* @param {object} o - { base, token, orgId?, idempotencyKey?, fetchImpl?, logImpl? }
|
|
256
284
|
* @returns {Promise<object>} res frame
|
|
257
285
|
*/
|
|
258
286
|
export async function call(method, params, o = {}) {
|
|
@@ -261,15 +289,45 @@ export async function call(method, params, o = {}) {
|
|
|
261
289
|
const def = methodDef(method);
|
|
262
290
|
if (!def) return errFrame("NOT_FOUND", `unknown method ${method}`);
|
|
263
291
|
|
|
292
|
+
// Project the caller's params onto the names hq's zod schemas actually read.
|
|
293
|
+
// Pure + total; a method with no contract entry passes through unchanged.
|
|
294
|
+
let wire = params || {};
|
|
295
|
+
try {
|
|
296
|
+
const report = normalizeParams(method, wire);
|
|
297
|
+
wire = report.params;
|
|
298
|
+
logReportOnce(method, report, o.logImpl);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
// Fail-open, but NEVER SILENT. Every production bug in this system has been
|
|
301
|
+
// a quiet catch: the org mesh 401'd for hours without a line, the daemon
|
|
302
|
+
// read the wrong config file and logged "not configured", the inbound filter
|
|
303
|
+
// dropped six DMs saying nothing. If the param contract throws, the call
|
|
304
|
+
// still goes out with the RAW params — which is very likely the drift this
|
|
305
|
+
// module exists to correct — so the operator has to be told, or they debug a
|
|
306
|
+
// BAD_REQUEST with no idea the normaliser bailed.
|
|
307
|
+
try {
|
|
308
|
+
const log = typeof o.logImpl === "function" ? o.logImpl : console.warn;
|
|
309
|
+
log(
|
|
310
|
+
`[org] param contract failed for ${method} (${err && err.message}) — ` +
|
|
311
|
+
`sending RAW params; wire-name drift will NOT be corrected for this call`,
|
|
312
|
+
);
|
|
313
|
+
} catch { /* never throw from logging */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
264
316
|
const headers = baseHeaders(def.auth === false ? "" : token, def.auth === false ? "" : orgId);
|
|
265
317
|
if (def.sideEffecting) {
|
|
266
|
-
|
|
318
|
+
// Key off the ORIGINAL params too: the contract may have renamed the id the
|
|
319
|
+
// legacy key derivation reads.
|
|
320
|
+
const key =
|
|
321
|
+
o.idempotencyKey ||
|
|
322
|
+
(def.idempotent
|
|
323
|
+
? defaultIdempotencyKey(method, wire) || defaultIdempotencyKey(method, params)
|
|
324
|
+
: "");
|
|
267
325
|
if (key) headers["x-idempotency-key"] = String(key);
|
|
268
326
|
}
|
|
269
327
|
const r = await httpRequest(o.fetchImpl, v1Url(base, method), {
|
|
270
328
|
method: "POST",
|
|
271
329
|
headers,
|
|
272
|
-
body: JSON.stringify(
|
|
330
|
+
body: JSON.stringify(wire),
|
|
273
331
|
});
|
|
274
332
|
return asResFrame(r);
|
|
275
333
|
}
|
|
@@ -553,6 +611,13 @@ export async function fetchHierarchy(o = {}) {
|
|
|
553
611
|
|
|
554
612
|
/**
|
|
555
613
|
* register self with the org (registry.register). Idempotent.
|
|
614
|
+
*
|
|
615
|
+
* Accepts EITHER a rich maestro self-entry (assembleSelfEntry shape) or an
|
|
616
|
+
* already-projected registerSchema params object: `call()` runs the wire contract
|
|
617
|
+
* (`toRegisterParams`) and hq's `.strict()` schema sees only
|
|
618
|
+
* {displayName, archetype, humanSponsor, card}. Passing the raw entry used to
|
|
619
|
+
* 400 on every boot while the caller logged success.
|
|
620
|
+
*
|
|
556
621
|
* @param {object} entry directory entry (id, displayName, role, focus, pubkey…)
|
|
557
622
|
* @param {object} o - { base, token, idempotencyKey?, fetchImpl? }
|
|
558
623
|
*/
|
|
@@ -1559,28 +1624,17 @@ export function brandingRestoreFoundation(params, o = {}) {
|
|
|
1559
1624
|
* models { displayName, archetype:string, humanSponsor, card } — so we MUST trim
|
|
1560
1625
|
* here or a rich entry 400s (BAD_REQUEST). Everything the server doesn't model is
|
|
1561
1626
|
* preserved REDACTION-SAFE under `card` (the handler records only a `hasCard`
|
|
1562
|
-
* boolean on the chain, never the blob).
|
|
1627
|
+
* boolean on the chain, never the blob).
|
|
1628
|
+
*
|
|
1629
|
+
* THE projection now lives in lib/org/param-contract (`toRegisterParams`) — the
|
|
1630
|
+
* same table `call()` applies — so `client.register(rawEntry)` and
|
|
1631
|
+
* `publishEntry({entry})` can no longer disagree about the shape. Re-exported
|
|
1632
|
+
* under the historical name for the callers that already import it.
|
|
1563
1633
|
* @param {object} entry
|
|
1564
1634
|
* @returns {object} registerSchema-shaped params
|
|
1565
1635
|
*/
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const displayName = e.fullName || e.name || e.displayName || String(e.id || "");
|
|
1569
|
-
// archetype is an object in the maestro entry ({function,altitude,label}); the
|
|
1570
|
-
// server wants a single string. Prefer the human label, then the function.
|
|
1571
|
-
const arch = e.archetype;
|
|
1572
|
-
const archetype =
|
|
1573
|
-
typeof arch === "string"
|
|
1574
|
-
? arch
|
|
1575
|
-
: (arch && (arch.label || arch.function || arch.altitude)) || "";
|
|
1576
|
-
const humanSponsor =
|
|
1577
|
-
(e.principal && (e.principal.fullName || e.principal.title)) || e.humanSponsor || "";
|
|
1578
|
-
const params = { card: e };
|
|
1579
|
-
if (displayName) params.displayName = String(displayName).slice(0, 200);
|
|
1580
|
-
if (archetype) params.archetype = String(archetype).slice(0, 120);
|
|
1581
|
-
if (humanSponsor) params.humanSponsor = String(humanSponsor).slice(0, 200);
|
|
1582
|
-
return params;
|
|
1583
|
-
}
|
|
1636
|
+
const entryToRegisterParams = toRegisterParams;
|
|
1637
|
+
export { entryToRegisterParams };
|
|
1584
1638
|
|
|
1585
1639
|
/**
|
|
1586
1640
|
* Publish (upsert) this agent's directory entry — now via the /v1 binding
|
package/lib/org/client.test.mjs
CHANGED
|
@@ -8,9 +8,22 @@
|
|
|
8
8
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Run `fn` with console.warn captured; returns the captured lines. */
|
|
16
|
+
function captureWarn(fn) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const real = console.warn;
|
|
19
|
+
console.warn = (...a) => { lines.push(a.join(" ")); };
|
|
20
|
+
try { fn(); } finally { console.warn = real; }
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
11
23
|
|
|
12
24
|
import {
|
|
13
25
|
isEnabled,
|
|
26
|
+
loadOrgConfig,
|
|
14
27
|
configFromAgent,
|
|
15
28
|
pairRequest,
|
|
16
29
|
call,
|
|
@@ -862,7 +875,9 @@ test("knowledgeSearch: side-effecting (POST), Bearer set, posts the query body",
|
|
|
862
875
|
assert.equal(f.calls[0].init.method, "POST");
|
|
863
876
|
assert.equal(f.calls[0].url, `${BASE}/api/v1/knowledge.search`);
|
|
864
877
|
assert.equal(f.calls[0].headers.authorization, `Bearer ${TOKEN}`);
|
|
865
|
-
|
|
878
|
+
// The wire contract canonicalises `query`→`q` (hq reads `p.q ?? p.query`) and
|
|
879
|
+
// keeps the legacy name alongside for an older server.
|
|
880
|
+
assert.deepEqual(JSON.parse(f.calls[0].init.body), { query: "q", q: "q", group: "org", limit: 5 });
|
|
866
881
|
assert.deepEqual(frame.result.returned_ids, ["k-1"]);
|
|
867
882
|
});
|
|
868
883
|
|
|
@@ -1105,3 +1120,41 @@ test("fetchHierarchy: GET /v1/hierarchy bare payload → {members,reporting}; nu
|
|
|
1105
1120
|
const fail = fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "INTERNAL", message: "down" } } }));
|
|
1106
1121
|
assert.equal(await fetchHierarchy({ base: BASE, token: TOKEN, orgId: "acme", fetchImpl: fail }), null);
|
|
1107
1122
|
});
|
|
1123
|
+
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
// loadOrgConfig — an enrolled agent must never go quietly un-enrolled
|
|
1126
|
+
// ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
test("loadOrgConfig: absent org.yaml is silent (an un-enrolled agent is not a fault)", () => {
|
|
1129
|
+
const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
|
|
1130
|
+
const warnings = captureWarn(() => {
|
|
1131
|
+
assert.deepEqual(loadOrgConfig(dir), {});
|
|
1132
|
+
});
|
|
1133
|
+
assert.deepEqual(warnings, [], "no file, no noise");
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test("loadOrgConfig: parses config/org.yaml", () => {
|
|
1137
|
+
const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
|
|
1138
|
+
mkdirSync(join(dir, "config"), { recursive: true });
|
|
1139
|
+
writeFileSync(
|
|
1140
|
+
join(dir, "config", "org.yaml"),
|
|
1141
|
+
"org:\n cohort:\n enabled: true\n base: https://os.example.com\n",
|
|
1142
|
+
);
|
|
1143
|
+
const cfg = loadOrgConfig(dir);
|
|
1144
|
+
assert.equal(cfg.org.cohort.enabled, true);
|
|
1145
|
+
assert.equal(isEnabled(cfg), true);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un-enrols)", () => {
|
|
1149
|
+
// Returning {} quietly is how an enrolled agent ends up out of the mesh:
|
|
1150
|
+
// isEnabled({}) is false, so the daemon no-ops, never beats, and cannot
|
|
1151
|
+
// receive `halt`. Fail-open is right; silence is not.
|
|
1152
|
+
const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
|
|
1153
|
+
mkdirSync(join(dir, "config"), { recursive: true });
|
|
1154
|
+
writeFileSync(join(dir, "config", "org.yaml"), "org:\n cohort:\n - [unbalanced\n");
|
|
1155
|
+
let cfg;
|
|
1156
|
+
const warnings = captureWarn(() => { cfg = loadOrgConfig(dir); });
|
|
1157
|
+
assert.deepEqual(cfg, {}, "still fail-open");
|
|
1158
|
+
assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
|
|
1159
|
+
assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
|
|
1160
|
+
});
|
package/lib/org/doctor.mjs
CHANGED
|
@@ -190,6 +190,70 @@ export async function checkOrgConnectivity(o = {}) {
|
|
|
190
190
|
results.push({ level: "warn", msg: `pairing probe inconclusive (${chan.error?.code || "?"}: ${chan.error?.message || "no detail"})` });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// ── 3b. INBOUND PATH probe — can this agent actually RECEIVE a message? ───
|
|
194
|
+
//
|
|
195
|
+
// Pairing proves the key is bound to a member. It does NOT prove the agent
|
|
196
|
+
// can hear anything, and those are different failures with the same healthy
|
|
197
|
+
// appearance. In production an enrolled, beating, correctly-paired agent was
|
|
198
|
+
// deaf for hours: the inbound cadence read org config from the wrong file, and
|
|
199
|
+
// the history call sent `channel` where hq validates `channelId`. Doctor was
|
|
200
|
+
// green throughout, because nothing exercised the receive path.
|
|
201
|
+
//
|
|
202
|
+
// So exercise it: read history from a real channel the agent belongs to. That
|
|
203
|
+
// single call covers the param contract, the read scope, and the ACL. A
|
|
204
|
+
// BAD_REQUEST here means protocol drift between this SDK and the server —
|
|
205
|
+
// the class of bug that leaves a fresh machine silently mute.
|
|
206
|
+
if (chan.ok) {
|
|
207
|
+
const rooms = chan.result && (Array.isArray(chan.result.channels) ? chan.result.channels : Array.isArray(chan.result) ? chan.result : []);
|
|
208
|
+
const probeRoom = (rooms || []).map((c) => c && (c.id || c.channelId)).find(Boolean);
|
|
209
|
+
if (!probeRoom) {
|
|
210
|
+
results.push({ level: "warn", msg: "inbound probe skipped — this member is in no channels yet" });
|
|
211
|
+
} else {
|
|
212
|
+
const hist = await call("messaging.history", { channelId: probeRoom, limit: 1 }, callOpts);
|
|
213
|
+
if (hist.ok) {
|
|
214
|
+
results.push({ level: "ok", msg: "inbound path works (messaging.history readable)" });
|
|
215
|
+
} else if (hist.error && hist.error.code === "BAD_REQUEST") {
|
|
216
|
+
results.push({
|
|
217
|
+
level: "fail",
|
|
218
|
+
msg:
|
|
219
|
+
`inbound path BROKEN — messaging.history rejected this SDK's params ` +
|
|
220
|
+
`(${hist.error.message || "BAD_REQUEST"}). The agent cannot read messages ` +
|
|
221
|
+
`sent to it. This is SDK/server protocol drift: upgrade with ` +
|
|
222
|
+
`\`npm update @cohortapp/agent-sdk && npx @cohortapp/agent-sdk upgrade\``,
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
results.push({
|
|
226
|
+
level: "warn",
|
|
227
|
+
msg: `inbound probe inconclusive (${hist.error?.code || "?"}: ${hist.error?.message || "no detail"})`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── 3c. Is org enrolment visible to the DAEMON's config loader? ───────────
|
|
234
|
+
//
|
|
235
|
+
// The org client reads config/org.yaml; the cadence guards read the agent cfg.
|
|
236
|
+
// When those disagree the agent enrols successfully and still ignores every
|
|
237
|
+
// inbound event, because each org-facing guard bails out with "org messaging
|
|
238
|
+
// not configured". Assert the daemon's own view here rather than the client's.
|
|
239
|
+
try {
|
|
240
|
+
const { loadOrgConfig: loadOrg } = await import("./client.mjs");
|
|
241
|
+
const daemonView = loadOrg(agentRoot);
|
|
242
|
+
if (!daemonView || !daemonView.org || !daemonView.org.cohort) {
|
|
243
|
+
results.push({
|
|
244
|
+
level: "fail",
|
|
245
|
+
msg:
|
|
246
|
+
"org enrolment is NOT visible to the daemon's config loader — every " +
|
|
247
|
+
"org-facing cadence (messaging-inbound, brand-steward, …) will silently " +
|
|
248
|
+
"do nothing. Check config/org.yaml exists and js-yaml is installed.",
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
results.push({ level: "ok", msg: "org enrolment visible to the daemon cadences" });
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
results.push({ level: "warn", msg: `daemon org-config check failed (${err && err.message})` });
|
|
255
|
+
}
|
|
256
|
+
|
|
193
257
|
// ── 4/5. workspace mailbox ───────────────────────────────────────────────
|
|
194
258
|
const orgmailGate = existsSync(join(agentRoot, "config", "orgmail.yaml"));
|
|
195
259
|
if (orgmailGate) {
|
package/lib/org/doctor.test.mjs
CHANGED
|
@@ -62,6 +62,8 @@ function stubFetch(routes) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const DIR_OK = { status: 200, body: { members: [] }, headers: { "x-org-protocol": String(PROTOCOL_VERSION) } };
|
|
65
|
+
const HISTORY_OK = { status: 200, body: { ok: true, result: { messages: [] } } };
|
|
66
|
+
const HISTORY_BAD_PARAMS = { status: 400, body: { ok: false, error: { code: "BAD_REQUEST", message: "channelId: Required" } } };
|
|
65
67
|
const CHANNELS_OK = { status: 200, body: { ok: true, result: { channels: [{ id: "c1" }, { id: "c2" }] } } };
|
|
66
68
|
|
|
67
69
|
const levelsOf = (rs) => rs.map((r) => r.level);
|
|
@@ -80,10 +82,17 @@ test("probe 1: not enrolled → single ok line, no network", async () => {
|
|
|
80
82
|
|
|
81
83
|
test("probe 2: happy path — reachable + latency + aligned protocol header + paired", async () => {
|
|
82
84
|
const r = root();
|
|
83
|
-
const f = stubFetch({
|
|
85
|
+
const f = stubFetch({
|
|
86
|
+
"/api/v1/directory": DIR_OK,
|
|
87
|
+
"/api/v1/messaging.channels": CHANNELS_OK,
|
|
88
|
+
"/api/v1/messaging.history": HISTORY_OK,
|
|
89
|
+
});
|
|
84
90
|
let t = 1000;
|
|
85
91
|
const rs = await checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {}, now: () => (t += 40) });
|
|
86
|
-
|
|
92
|
+
// …reachable, protocol, paired, INBOUND PATH, daemon-visible org config.
|
|
93
|
+
assert.deepEqual(levelsOf(rs), ["ok", "ok", "ok", "ok", "ok", "ok"]);
|
|
94
|
+
assert.match(msgs(rs), /inbound path works/);
|
|
95
|
+
assert.match(msgs(rs), /visible to the daemon cadences/);
|
|
87
96
|
assert.match(msgs(rs), /directory \d+ms/);
|
|
88
97
|
assert.match(msgs(rs), /protocol version aligned/);
|
|
89
98
|
assert.match(msgs(rs), /paired to a workforce member \(2 channel\(s\)\)/);
|
|
@@ -210,3 +219,23 @@ test("no orgmail gate file → no mailbox probe at all", async () => {
|
|
|
210
219
|
assert.ok(!f.calls.some((c) => c.url.includes("email.inbox")));
|
|
211
220
|
rmSync(r, { recursive: true, force: true });
|
|
212
221
|
});
|
|
222
|
+
|
|
223
|
+
test("inbound probe FAILS loudly when the server rejects this SDK's params", () => {
|
|
224
|
+
// The real incident: fetchHistory sent `channel`, hq validates `channelId`,
|
|
225
|
+
// so the agent could not read a single message sent to it — while every other
|
|
226
|
+
// doctor line stayed green. Protocol drift must be a FAIL, not a shrug.
|
|
227
|
+
const r = root();
|
|
228
|
+
const f = stubFetch({
|
|
229
|
+
"/api/v1/directory": DIR_OK,
|
|
230
|
+
"/api/v1/messaging.channels": CHANNELS_OK,
|
|
231
|
+
"/api/v1/messaging.history": HISTORY_BAD_PARAMS,
|
|
232
|
+
});
|
|
233
|
+
return checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {} }).then((rs) => {
|
|
234
|
+
const bad = rs.find((x) => /inbound path BROKEN/.test(x.msg));
|
|
235
|
+
assert.ok(bad, "a rejected inbound probe must surface");
|
|
236
|
+
assert.equal(bad.level, "fail");
|
|
237
|
+
assert.match(bad.msg, /protocol drift/);
|
|
238
|
+
assert.match(bad.msg, /upgrade/);
|
|
239
|
+
rmSync(r, { recursive: true, force: true });
|
|
240
|
+
});
|
|
241
|
+
});
|