@cohortapp/agent-sdk 2.5.1 → 2.6.1
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 +305 -89
- package/bin/maestro.test.mjs +357 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/classifier.test.mjs +18 -9
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ui-parity.test.mjs — Agent UI-parity helpers (full human-action mirror).
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The 359 wrappers in ui-parity.mjs are each a one-liner over call() from
|
|
5
5
|
* client.mjs, so client.test.mjs already proves the transport contract (headers,
|
|
6
6
|
* idempotency, fail-open, frame normalisation). Here we just prove a representative
|
|
7
7
|
* slice across families ROUTES to the correct method name, POSTs the params body,
|
|
@@ -326,7 +326,7 @@ test("a human-gated directory verb surfaces the server's refusal verbatim, never
|
|
|
326
326
|
assert.match(frame.error.message, /human/);
|
|
327
327
|
});
|
|
328
328
|
|
|
329
|
-
test("every desk protocol method has exactly one ui-parity wrapper (196 desks /
|
|
329
|
+
test("every desk protocol method has exactly one ui-parity wrapper (196 desks / 359 total)", async () => {
|
|
330
330
|
const fs = await import("node:fs");
|
|
331
331
|
const path = await import("node:path");
|
|
332
332
|
const url = await import("node:url");
|
|
@@ -335,8 +335,22 @@ test("every desk protocol method has exactly one ui-parity wrapper (196 desks /
|
|
|
335
335
|
const called = [...src.matchAll(/return call\("([a-z]+\.[A-Za-z]+)"/g)].map((x) => x[1]);
|
|
336
336
|
// 2026-08 mobile-parity delta: +16 desk methods (email 5, files 9,
|
|
337
337
|
// calendar.ask, crm.ask) → 312 → 328.
|
|
338
|
-
|
|
338
|
+
// 2026-08 conversation-parity delta: the module claimed "one per agent-facing
|
|
339
|
+
// org method the human app exposes" while carrying 6 of messaging's 15 and
|
|
340
|
+
// NONE of calling's 22 — so the huddle control, the calendar Join, host
|
|
341
|
+
// mute/lock/remove, hand-raising and in-call chat had no wrapper at all.
|
|
342
|
+
// +9 messaging +22 calling → 328 → 359.
|
|
343
|
+
assert.equal(called.length, 359, "one call() site per wrapper");
|
|
339
344
|
assert.equal(new Set(called).size, called.length, "no duplicate method bindings");
|
|
345
|
+
// The two conversation families are now WHOLE, which is what makes the
|
|
346
|
+
// module docblock's claim true rather than aspirational.
|
|
347
|
+
for (const fam of ["messaging", "calling"]) {
|
|
348
|
+
const famMethods = Object.entries((await import("./protocol.mjs")).METHODS)
|
|
349
|
+
.filter(([, d]) => d.family === fam)
|
|
350
|
+
.map(([n]) => n);
|
|
351
|
+
const unwrapped = famMethods.filter((m) => !new Set(called).has(m));
|
|
352
|
+
assert.deepEqual(unwrapped, [], `every ${fam}.* method is wrapped`);
|
|
353
|
+
}
|
|
340
354
|
const p = await import("./protocol.mjs");
|
|
341
355
|
const deskFams = new Set(["books", "calendar", "crm", "directory", "files"]);
|
|
342
356
|
const deskMethods = Object.entries(p.METHODS)
|
|
@@ -346,3 +360,82 @@ test("every desk protocol method has exactly one ui-parity wrapper (196 desks /
|
|
|
346
360
|
const missing = deskMethods.filter((m) => !bound.has(m));
|
|
347
361
|
assert.deepEqual(missing, [], "every desk method is wrapped");
|
|
348
362
|
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The methods in a CLAIMED family that ui-parity deliberately does NOT wrap,
|
|
366
|
+
* with the reason. The module docblock lists its families flatly, which reads as
|
|
367
|
+
* "every method in these families" — so the exceptions have to be written down
|
|
368
|
+
* and enforced, or the docblock is an orphan describing a surface that is not
|
|
369
|
+
* there. Ledger, not an excuse list: each line says which OTHER surface carries
|
|
370
|
+
* the verb.
|
|
371
|
+
*/
|
|
372
|
+
const DELIBERATELY_UNWRAPPED = new Map([
|
|
373
|
+
// board — the agent work kernel; no human control, curated tools instead.
|
|
374
|
+
["board.create", "agent work kernel"],
|
|
375
|
+
["board.claim", "agent work kernel — curated board_claim"],
|
|
376
|
+
["board.heartbeat", "agent work kernel (lease slide)"],
|
|
377
|
+
["board.complete", "agent work kernel — curated board_complete"],
|
|
378
|
+
["board.block", "agent work kernel"],
|
|
379
|
+
["board.comment", "agent work kernel (kanban comments = board.addTaskComment)"],
|
|
380
|
+
["board.decompose", "agent work kernel — governance-gated §0.4"],
|
|
381
|
+
["board.assign", "agent work kernel — governance-gated §0.4"],
|
|
382
|
+
["board.link", "agent work kernel (DAG edge)"],
|
|
383
|
+
["board.requestReview", "agent work kernel"],
|
|
384
|
+
["board.resolveReview", "agent work kernel"],
|
|
385
|
+
// A human never calls this: it records where an AGENT is on an ask it took
|
|
386
|
+
// on. The daemon drives it from its own dispatch path (lib/org/work-ledger)
|
|
387
|
+
// and a session may nudge it mid-work via the curated `work_track` tool.
|
|
388
|
+
["board.track", "agent work kernel — daemon-driven, curated work_track"],
|
|
389
|
+
// decision — the §0.4 control-plane acts, not the human ledger surface.
|
|
390
|
+
["decision.propose", "control plane — curated decision_propose"],
|
|
391
|
+
["decision.adopt", "control plane — governance-gated §0.4"],
|
|
392
|
+
["decision.supersede", "control plane — governance-gated §0.4"],
|
|
393
|
+
// the rest — a different artifact or a different plane entirely.
|
|
394
|
+
["escalation.ask", "OpenQuestion artifact, not the escalation strip"],
|
|
395
|
+
["escalation.answer", "OpenQuestion artifact, not the escalation strip"],
|
|
396
|
+
["memory.recall", "semantic retrieval — curated memory_recall"],
|
|
397
|
+
["integration.toolsetVersion", "runtime toolset plane (daemon poll)"],
|
|
398
|
+
["integration.listAgentTools", "runtime toolset plane (daemon poll)"],
|
|
399
|
+
["integration.invokeTool", "runtime toolset plane (executor)"],
|
|
400
|
+
["meetings.record", "knowledge plane writer, not the desk verb"],
|
|
401
|
+
]);
|
|
402
|
+
|
|
403
|
+
test("the claimed families are wrapped WHOLE, minus a declared, self-cleaning exception list", async () => {
|
|
404
|
+
const fs = await import("node:fs");
|
|
405
|
+
const path = await import("node:path");
|
|
406
|
+
const url = await import("node:url");
|
|
407
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
408
|
+
const src = fs.readFileSync(path.join(here, "ui-parity.mjs"), "utf8");
|
|
409
|
+
const wrapped = new Set([...src.matchAll(/return call\("([a-z]+\.[A-Za-z]+)"/g)].map((x) => x[1]));
|
|
410
|
+
const p = await import("./protocol.mjs");
|
|
411
|
+
|
|
412
|
+
// Exactly the families the module docblock names (branding lives in client.mjs).
|
|
413
|
+
const CLAIMED = [
|
|
414
|
+
"charter", "member", "team", "channel", "persona", "profile", "sop",
|
|
415
|
+
"escalation", "annotation", "contact", "file", "memory", "compact",
|
|
416
|
+
"notification", "decision", "messaging", "calling", "board", "invitation",
|
|
417
|
+
"user", "settings", "billing", "org", "integration", "email", "artifact",
|
|
418
|
+
"books", "calendar", "crm", "directory", "files",
|
|
419
|
+
];
|
|
420
|
+
const undeclared = [];
|
|
421
|
+
for (const name of Object.keys(p.METHODS)) {
|
|
422
|
+
const fam = name.slice(0, name.indexOf("."));
|
|
423
|
+
if (!CLAIMED.includes(fam)) continue;
|
|
424
|
+
if (wrapped.has(name)) continue;
|
|
425
|
+
if (DELIBERATELY_UNWRAPPED.has(name)) continue;
|
|
426
|
+
undeclared.push(name);
|
|
427
|
+
}
|
|
428
|
+
// If this fails: add the wrapper, or add a DELIBERATELY_UNWRAPPED line saying
|
|
429
|
+
// which surface carries the verb instead. Silence is the one option gone.
|
|
430
|
+
assert.deepEqual(undeclared, [], "every method of a claimed family is wrapped or declared");
|
|
431
|
+
|
|
432
|
+
// Self-cleaning both ways: a stale exception (now wrapped, or no longer a
|
|
433
|
+
// real method) must be deleted rather than left as decoration.
|
|
434
|
+
const nowWrapped = [...DELIBERATELY_UNWRAPPED.keys()].filter((m) => wrapped.has(m));
|
|
435
|
+
assert.deepEqual(nowWrapped, [], "these are wrapped now — delete their exception lines");
|
|
436
|
+
const notMethods = [...DELIBERATELY_UNWRAPPED.keys()].filter((m) => !p.methodDef(m));
|
|
437
|
+
assert.deepEqual(notMethods, [], "these are not protocol methods — delete their exception lines");
|
|
438
|
+
|
|
439
|
+
// And the docblock's own numbers stay honest.
|
|
440
|
+
assert.equal(wrapped.size, 359, "the docblock's wrapper count");
|
|
441
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/work-ledger.mjs — the daemon's half of "make the work visible".
|
|
3
|
+
*
|
|
4
|
+
* THE COMPLAINT THIS EXISTS FOR. A colleague writes to the agent. The typing
|
|
5
|
+
* indicator comes on. Then nothing — for fifteen, thirty, forty-five minutes,
|
|
6
|
+
* because the reply IS the session and the session is long. Meanwhile the board
|
|
7
|
+
* says nothing was ever asked, nothing is in progress, and when the session dies
|
|
8
|
+
* (it does: `onClose({ok:false})` emits telemetry and messages nobody) the board
|
|
9
|
+
* still says nothing. The work is invisible from the moment it is accepted to
|
|
10
|
+
* the moment it silently fails.
|
|
11
|
+
*
|
|
12
|
+
* THE FIX IS NOT HERE. It is in hq's `server/work/ledger.ts`, which hq's own
|
|
13
|
+
* LLM responder calls directly and which this module reaches over the wire as
|
|
14
|
+
* `board.track`. All of the judgement — whether the ask deserves a board row,
|
|
15
|
+
* find-or-open, the column ladder, comment de-duplication, attachment de-
|
|
16
|
+
* duplication, who gets @-tagged and when — lives THERE, once. This file maps a
|
|
17
|
+
* daemon inbox item onto that call and gets out of the way.
|
|
18
|
+
*
|
|
19
|
+
* That asymmetry is deliberate. `4d762d30` means a member is animated by hq OR
|
|
20
|
+
* by their daemon, never both, and the owner must not be able to tell which is
|
|
21
|
+
* driving. Two implementations of "when does an ask deserve a board row" would
|
|
22
|
+
* be two answers within a week. So there is one, and this is a client of it.
|
|
23
|
+
*
|
|
24
|
+
* IDENTITY, NOT DIGESTS. We send the ask's identity (`service`, `channelId`,
|
|
25
|
+
* `messageId`) and let the server derive the dedupe key. The hashing algorithm
|
|
26
|
+
* therefore exists in exactly one repo, and a daemon can never compute a
|
|
27
|
+
* different key than hq would for the same message — which is what makes
|
|
28
|
+
* re-delivery after a dead session find the SAME row instead of opening a
|
|
29
|
+
* second one.
|
|
30
|
+
*
|
|
31
|
+
* FAIL-OPEN, NEVER SILENT. Every path returns a benign `{tracked:false, reason}`
|
|
32
|
+
* rather than throwing: a board write must never cost the user their reply. But
|
|
33
|
+
* the reason always comes back and the daemon always logs it — the entire reason
|
|
34
|
+
* this workstream exists is that a process died quietly.
|
|
35
|
+
*
|
|
36
|
+
* Pure-core + injectable IO: `trackImpl` / `cfg` / `fetchImpl` are all
|
|
37
|
+
* injectable, so the tests drive the real logic with no server. ESM, Node
|
|
38
|
+
* builtins only.
|
|
39
|
+
*
|
|
40
|
+
* @module lib/org/work-ledger
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
"use strict";
|
|
44
|
+
|
|
45
|
+
import { isEnabled as orgEnabled, configFromAgent, trackWork } from "./client.mjs";
|
|
46
|
+
|
|
47
|
+
/** The stages the ledger understands. Mirrors hq's `WorkStage`. */
|
|
48
|
+
export const WORK_STAGES = ["accepted", "working", "blocked", "review", "done", "failed"];
|
|
49
|
+
|
|
50
|
+
const MAX_NOTE = 4000;
|
|
51
|
+
const MAX_TITLE = 200;
|
|
52
|
+
const MAX_BODY = 20000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Which Cohort channel did this inbox item arrive on?
|
|
56
|
+
*
|
|
57
|
+
* A board row inherits the visibility of the channel it lands on, so this is
|
|
58
|
+
* also the privacy answer: a DM's task lands on the DM's own board and is seen
|
|
59
|
+
* by exactly the DM's members. (That is strictly better than
|
|
60
|
+
* `session-outcomes.mjs`, which had to REFUSE every DM outright because
|
|
61
|
+
* `board.create` gives a channel-less row org-wide visibility. The owner's asks
|
|
62
|
+
* arrive by DM; refusing them meant refusing the actual use case.)
|
|
63
|
+
*
|
|
64
|
+
* Returns null for anything that is not a Cohort conversation — a Slack channel
|
|
65
|
+
* or an email thread is not a board we can write to, and guessing would put a
|
|
66
|
+
* task on a stranger's board.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} item inbox item
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function cohortChannelId(item) {
|
|
72
|
+
if (!item || typeof item !== "object") return null;
|
|
73
|
+
const service = String(item.service || "").trim().toLowerCase();
|
|
74
|
+
if (service !== "cohort") return null;
|
|
75
|
+
const id = String(item.cohort_channel_id || item.channel_id || "").trim();
|
|
76
|
+
return id || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The originating Cohort entity id — the thing the ask IS, which for a messaging
|
|
81
|
+
* surface is the message id.
|
|
82
|
+
*
|
|
83
|
+
* Read off `raw_ref` (`cohort:<surface>:<entityId>:<seq>`, stamped by
|
|
84
|
+
* `lib/org/inbound/project.mjs` and carried verbatim through the YAML
|
|
85
|
+
* round-trip), falling back to the inbox id, which `inboxIdFor` mints as
|
|
86
|
+
* `cohort-<messageId>` for messaging. Both are STABLE across a re-delivery —
|
|
87
|
+
* which is the whole point: a session that died and left the item un-processed
|
|
88
|
+
* comes back with the same id, computes the same key server-side, and finds the
|
|
89
|
+
* SAME row rather than opening a second one.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} item
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
export function cohortMessageId(item) {
|
|
95
|
+
if (!item || typeof item !== "object") return null;
|
|
96
|
+
const raw = String(item.raw_ref || "").trim();
|
|
97
|
+
if (raw.startsWith("cohort:")) {
|
|
98
|
+
const parts = raw.split(":");
|
|
99
|
+
if (parts.length >= 3 && parts[2]) return parts[2];
|
|
100
|
+
}
|
|
101
|
+
const id = String(item.id || "").trim();
|
|
102
|
+
if (id.startsWith("cohort-")) return id.slice("cohort-".length) || null;
|
|
103
|
+
return id || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The requester's Cohort member id.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately usually NULL. The flattened inbox item carries the sender's
|
|
110
|
+
* NAME, not their id (`lib/channels/inbox-item.mjs` collapses `from:{id,name}`
|
|
111
|
+
* to a single `sender` string), and a name is not something to tag anybody on.
|
|
112
|
+
* Rather than plumb a new field through the poller, the YAML and the parser, the
|
|
113
|
+
* server resolves the requester from the originating message's own `authorId` —
|
|
114
|
+
* which it already holds, and which a client cannot spoof. This reads an id only
|
|
115
|
+
* when a caller genuinely has one.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} item
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
export function requesterMemberId(item) {
|
|
121
|
+
if (!item || typeof item !== "object") return null;
|
|
122
|
+
const id = String(item.sender_member_id || item.cohort_author_id || "").trim();
|
|
123
|
+
return id || null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Clip a string, tolerating non-strings. */
|
|
127
|
+
function clip(s, n) {
|
|
128
|
+
return String(s == null ? "" : s).slice(0, n);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Record one step of the agent's work on the board.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} a
|
|
135
|
+
* @param {object} a.item the inbox item the work is for
|
|
136
|
+
* @param {string} a.stage one of {@link WORK_STAGES}
|
|
137
|
+
* @param {boolean} [a.deferred] could this NOT be answered inside the turn?
|
|
138
|
+
* (true for the session path — the same signal that fires the holding
|
|
139
|
+
* message; false for the quick-reply path). The server's gate reads it.
|
|
140
|
+
* @param {string} [a.title] row title (defaults server-side from the ask)
|
|
141
|
+
* @param {string} [a.detail] row detail, written once at open
|
|
142
|
+
* @param {string} [a.note] a progress comment for this step
|
|
143
|
+
* @param {string[]} [a.notify] extra member ids to @-tag on this step
|
|
144
|
+
* @param {Array<{name:string,mimeType?:string,sizeBytes?:number,dataUrl:string}>} [a.attachments]
|
|
145
|
+
* @param {object} [a.source] free provenance stamped into the row's `why`
|
|
146
|
+
* @param {object} [a.cfg] agent org config (config/org.yaml shape)
|
|
147
|
+
* @param {Function} [a.trackImpl] injectable board.track
|
|
148
|
+
* @param {Function} [a.fetchImpl] injectable fetch
|
|
149
|
+
* @returns {Promise<{tracked:boolean, reason:string, taskId?:string|null, col?:string|null,
|
|
150
|
+
* created?:boolean, moved?:boolean, commented?:boolean,
|
|
151
|
+
* attached?:number, tagged?:number}>}
|
|
152
|
+
*/
|
|
153
|
+
export async function recordWorkStep(a = {}) {
|
|
154
|
+
try {
|
|
155
|
+
const cfg = a.cfg || {};
|
|
156
|
+
if (!orgEnabled(cfg)) return { tracked: false, reason: "org-disabled" };
|
|
157
|
+
|
|
158
|
+
const stage = String(a.stage || "").trim();
|
|
159
|
+
if (!WORK_STAGES.includes(stage)) return { tracked: false, reason: "bad-stage" };
|
|
160
|
+
|
|
161
|
+
const channelId = cohortChannelId(a.item);
|
|
162
|
+
if (!channelId) return { tracked: false, reason: "not-a-cohort-channel" };
|
|
163
|
+
const messageId = cohortMessageId(a.item);
|
|
164
|
+
if (!messageId) return { tracked: false, reason: "no-message-id" };
|
|
165
|
+
|
|
166
|
+
const conn = configFromAgent(cfg);
|
|
167
|
+
const impl = a.trackImpl || trackWork;
|
|
168
|
+
|
|
169
|
+
const params = {
|
|
170
|
+
service: "cohort",
|
|
171
|
+
channelId,
|
|
172
|
+
messageId,
|
|
173
|
+
stage,
|
|
174
|
+
// The flattened inbox item carries the ask under `content`.
|
|
175
|
+
body: clip(a.item && (a.item.content || a.item.text || a.item.subject), MAX_BODY),
|
|
176
|
+
// `mentions_agent` is the ingest layer's REAL directedness verdict (a DM,
|
|
177
|
+
// an @mention, a named address, a direct assignment) — `project.mjs` sets
|
|
178
|
+
// it only when the address is genuine, never guessed. A channel message
|
|
179
|
+
// the agent merely overheard is not assigned work.
|
|
180
|
+
directed: !!(a.item && a.item.priority_signals && a.item.priority_signals.mentions_agent),
|
|
181
|
+
authorKind: a.item && a.item.author_kind === "AI_AGENT" ? "AI_AGENT" : "HUMAN",
|
|
182
|
+
deferred: a.deferred === true,
|
|
183
|
+
};
|
|
184
|
+
const requester = requesterMemberId(a.item);
|
|
185
|
+
if (requester) params.requesterId = requester;
|
|
186
|
+
if (a.title) params.title = clip(a.title, MAX_TITLE);
|
|
187
|
+
if (a.detail) params.detail = clip(a.detail, MAX_BODY);
|
|
188
|
+
if (a.note) params.note = clip(a.note, MAX_NOTE);
|
|
189
|
+
if (Array.isArray(a.notify) && a.notify.length) params.notify = a.notify.slice(0, 8);
|
|
190
|
+
if (Array.isArray(a.attachments) && a.attachments.length) {
|
|
191
|
+
params.attachments = a.attachments.slice(0, 6);
|
|
192
|
+
}
|
|
193
|
+
if (a.source && typeof a.source === "object") {
|
|
194
|
+
// The wire schema is `.strict()` and string-valued; drop anything else
|
|
195
|
+
// rather than have the whole step rejected for one stray field.
|
|
196
|
+
const src = {};
|
|
197
|
+
for (const [k, v] of Object.entries(a.source)) {
|
|
198
|
+
if (v == null) continue;
|
|
199
|
+
src[String(k)] = clip(v, 300);
|
|
200
|
+
}
|
|
201
|
+
if (Object.keys(src).length) params.source = src;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const res = await impl(params, {
|
|
205
|
+
base: conn.base,
|
|
206
|
+
token: conn.token,
|
|
207
|
+
fetchImpl: a.fetchImpl,
|
|
208
|
+
// Same ask + same stage = the same call. A retry after a timeout must not
|
|
209
|
+
// double-post the tag.
|
|
210
|
+
idempotencyKey: `board.track:${channelId}:${messageId}:${stage}`,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// The client never throws; a failure comes back as an error frame.
|
|
214
|
+
if (!res || res.error) {
|
|
215
|
+
const reason = res && res.error ? `${res.error.code || "error"}: ${res.error.message || ""}` : "no-response";
|
|
216
|
+
return { tracked: false, reason: reason.slice(0, 200) };
|
|
217
|
+
}
|
|
218
|
+
const out = res.result !== undefined ? res.result : res;
|
|
219
|
+
return {
|
|
220
|
+
tracked: !!(out && out.tracked),
|
|
221
|
+
reason: (out && out.reason) || "ok",
|
|
222
|
+
taskId: (out && out.taskId) || null,
|
|
223
|
+
col: (out && out.col) || null,
|
|
224
|
+
created: !!(out && out.created),
|
|
225
|
+
moved: !!(out && out.moved),
|
|
226
|
+
commented: !!(out && out.commented),
|
|
227
|
+
attached: (out && out.attached) || 0,
|
|
228
|
+
tagged: (out && out.tagged) || 0,
|
|
229
|
+
};
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return { tracked: false, reason: `error: ${err && err.message ? err.message : String(err)}`.slice(0, 200) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default {
|
|
236
|
+
WORK_STAGES,
|
|
237
|
+
cohortChannelId,
|
|
238
|
+
cohortMessageId,
|
|
239
|
+
requesterMemberId,
|
|
240
|
+
recordWorkStep,
|
|
241
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/org/work-ledger.test.mjs — the daemon's half of the work ledger.
|
|
3
|
+
*
|
|
4
|
+
* What is worth testing here is deliberately NARROW, because the policy is not
|
|
5
|
+
* here: the threshold, the idempotency and the tagging all live server-side in
|
|
6
|
+
* hq's `server/work/ledger.ts`, so re-testing them in this repo would be
|
|
7
|
+
* testing a second implementation that must not exist. What IS this module's
|
|
8
|
+
* job — and what these tests pin — is the mapping: does a real flattened inbox
|
|
9
|
+
* item become the right wire call, does a re-delivery of the SAME item produce
|
|
10
|
+
* the SAME identity, and does every failure path come back as a benign,
|
|
11
|
+
* REASONED refusal rather than an exception on the dispatch path.
|
|
12
|
+
*
|
|
13
|
+
* Run: node --test lib/org/work-ledger.test.mjs
|
|
14
|
+
*/
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
WORK_STAGES,
|
|
22
|
+
cohortChannelId,
|
|
23
|
+
cohortMessageId,
|
|
24
|
+
requesterMemberId,
|
|
25
|
+
recordWorkStep,
|
|
26
|
+
} from "./work-ledger.mjs";
|
|
27
|
+
|
|
28
|
+
const CFG = {
|
|
29
|
+
org: { cohort: { enabled: true, base: "https://os.example.test", token: "tok_test" } },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A flattened Cohort inbox item, in the shape lib/channels/inbox-item.mjs writes. */
|
|
33
|
+
function inboxItem(over = {}) {
|
|
34
|
+
return {
|
|
35
|
+
id: "cohort-msg_abc123",
|
|
36
|
+
service: "cohort",
|
|
37
|
+
channel: "Owner ↔ Isla",
|
|
38
|
+
channel_id: "ch_dm_1",
|
|
39
|
+
sender: "The Owner",
|
|
40
|
+
sender_privilege: "ceo",
|
|
41
|
+
timestamp: "2026-08-13T05:47:51.000Z",
|
|
42
|
+
subject: "Direct message",
|
|
43
|
+
content: "fix these issues you just noted please and push their fixes to git",
|
|
44
|
+
thread_id: "",
|
|
45
|
+
is_reply: false,
|
|
46
|
+
priority_signals: {
|
|
47
|
+
from_ceo: true,
|
|
48
|
+
tagged_urgent: false,
|
|
49
|
+
contains_deadline: false,
|
|
50
|
+
mentions_agent: true,
|
|
51
|
+
},
|
|
52
|
+
raw_ref: "cohort:message:msg_abc123:4821",
|
|
53
|
+
source: "cohort",
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Capture the params a step would send, without a server. */
|
|
59
|
+
function capture(result = { result: { tracked: true, taskId: "t_1", col: "triage", created: true } }) {
|
|
60
|
+
const calls = [];
|
|
61
|
+
const impl = async (params, opts) => {
|
|
62
|
+
calls.push({ params, opts });
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
return { calls, impl };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Identity — the thing that makes a re-delivery find the same row
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
test("cohortMessageId reads the entity id off raw_ref", () => {
|
|
73
|
+
assert.equal(cohortMessageId(inboxItem()), "msg_abc123");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("cohortMessageId falls back to the inbox id when raw_ref is absent", () => {
|
|
77
|
+
assert.equal(cohortMessageId(inboxItem({ raw_ref: "" })), "msg_abc123");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a re-delivered item yields the SAME identity", () => {
|
|
81
|
+
// The dead-session case: the item was never marked processed, so the poller
|
|
82
|
+
// hands back a byte-identical file. Same channel + same message = same key
|
|
83
|
+
// server-side = the same board row, not a second one.
|
|
84
|
+
const first = inboxItem();
|
|
85
|
+
const again = inboxItem();
|
|
86
|
+
assert.equal(cohortChannelId(first), cohortChannelId(again));
|
|
87
|
+
assert.equal(cohortMessageId(first), cohortMessageId(again));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("requesterMemberId is null for a normal item — the server resolves it", () => {
|
|
91
|
+
// The flat item carries the sender's NAME, not their id. Guessing a member id
|
|
92
|
+
// from a display name is how you tag the wrong person.
|
|
93
|
+
assert.equal(requesterMemberId(inboxItem()), null);
|
|
94
|
+
assert.equal(requesterMemberId({ sender_member_id: "m_owner" }), "m_owner");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// The mapping
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
test("an accepted step sends the ask's identity and the deferred fact", async () => {
|
|
102
|
+
const { calls, impl } = capture();
|
|
103
|
+
const res = await recordWorkStep({
|
|
104
|
+
item: inboxItem(),
|
|
105
|
+
stage: "accepted",
|
|
106
|
+
deferred: true,
|
|
107
|
+
note: "Picked this up.",
|
|
108
|
+
cfg: CFG,
|
|
109
|
+
trackImpl: impl,
|
|
110
|
+
source: { service: "cohort", trace_id: "tr_9" },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.equal(calls.length, 1);
|
|
114
|
+
const { params, opts } = calls[0];
|
|
115
|
+
assert.equal(params.service, "cohort");
|
|
116
|
+
assert.equal(params.channelId, "ch_dm_1");
|
|
117
|
+
assert.equal(params.messageId, "msg_abc123");
|
|
118
|
+
assert.equal(params.stage, "accepted");
|
|
119
|
+
assert.equal(params.deferred, true);
|
|
120
|
+
assert.equal(params.directed, true);
|
|
121
|
+
assert.equal(params.authorKind, "HUMAN");
|
|
122
|
+
assert.equal(
|
|
123
|
+
params.body,
|
|
124
|
+
"fix these issues you just noted please and push their fixes to git",
|
|
125
|
+
);
|
|
126
|
+
assert.equal(params.note, "Picked this up.");
|
|
127
|
+
assert.deepEqual(params.source, { service: "cohort", trace_id: "tr_9" });
|
|
128
|
+
// Same ask + same stage = the same call, so a retry after a timeout cannot
|
|
129
|
+
// double-post the tag.
|
|
130
|
+
assert.equal(opts.idempotencyKey, "board.track:ch_dm_1:msg_abc123:accepted");
|
|
131
|
+
assert.equal(opts.base, CFG.org.cohort.base);
|
|
132
|
+
assert.equal(res.tracked, true);
|
|
133
|
+
assert.equal(res.taskId, "t_1");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("directedness comes from the ingest layer's real verdict, never a guess", async () => {
|
|
137
|
+
const { calls, impl } = capture();
|
|
138
|
+
await recordWorkStep({
|
|
139
|
+
item: inboxItem({
|
|
140
|
+
priority_signals: { mentions_agent: false, from_ceo: false, tagged_urgent: false, contains_deadline: false },
|
|
141
|
+
}),
|
|
142
|
+
stage: "accepted",
|
|
143
|
+
cfg: CFG,
|
|
144
|
+
trackImpl: impl,
|
|
145
|
+
});
|
|
146
|
+
// An overheard channel message is not assigned work — and the SERVER is what
|
|
147
|
+
// acts on that, so the client's job is only to report it honestly.
|
|
148
|
+
assert.equal(calls[0].params.directed, false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a session death is reported as `failed`, not swallowed", async () => {
|
|
152
|
+
const { calls, impl } = capture({ result: { tracked: true, taskId: "t_1", col: "blocked", moved: true } });
|
|
153
|
+
const res = await recordWorkStep({
|
|
154
|
+
item: inboxItem(),
|
|
155
|
+
stage: "failed",
|
|
156
|
+
deferred: true,
|
|
157
|
+
note: "The session handling this ended without completing (exit 143).",
|
|
158
|
+
cfg: CFG,
|
|
159
|
+
trackImpl: impl,
|
|
160
|
+
});
|
|
161
|
+
assert.equal(calls[0].params.stage, "failed");
|
|
162
|
+
assert.equal(res.col, "blocked");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("attachments and extra tags are forwarded, and bounded", async () => {
|
|
166
|
+
const { calls, impl } = capture();
|
|
167
|
+
await recordWorkStep({
|
|
168
|
+
item: inboxItem(),
|
|
169
|
+
stage: "review",
|
|
170
|
+
cfg: CFG,
|
|
171
|
+
trackImpl: impl,
|
|
172
|
+
notify: Array.from({ length: 20 }, (_, i) => `m_${i}`),
|
|
173
|
+
attachments: Array.from({ length: 20 }, (_, i) => ({ name: `f${i}.txt`, dataUrl: "data:text/plain;base64,eA==" })),
|
|
174
|
+
});
|
|
175
|
+
assert.equal(calls[0].params.notify.length, 8);
|
|
176
|
+
assert.equal(calls[0].params.attachments.length, 6);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Fail-open, never silent
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
test("a non-Cohort item is refused with a reason, not an exception", async () => {
|
|
184
|
+
const { calls, impl } = capture();
|
|
185
|
+
const res = await recordWorkStep({ item: inboxItem({ service: "slack" }), stage: "accepted", cfg: CFG, trackImpl: impl });
|
|
186
|
+
assert.equal(res.tracked, false);
|
|
187
|
+
assert.equal(res.reason, "not-a-cohort-channel");
|
|
188
|
+
assert.equal(calls.length, 0, "no wire call for a board we cannot write to");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an item with no resolvable channel is refused", async () => {
|
|
192
|
+
const res = await recordWorkStep({ item: inboxItem({ channel_id: "" }), stage: "accepted", cfg: CFG, trackImpl: async () => ({}) });
|
|
193
|
+
assert.equal(res.tracked, false);
|
|
194
|
+
assert.equal(res.reason, "not-a-cohort-channel");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a bad stage is refused rather than sent", async () => {
|
|
198
|
+
const { calls, impl } = capture();
|
|
199
|
+
const res = await recordWorkStep({ item: inboxItem(), stage: "finished", cfg: CFG, trackImpl: impl });
|
|
200
|
+
assert.equal(res.reason, "bad-stage");
|
|
201
|
+
assert.equal(calls.length, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("org integration off ⇒ a clean skip", async () => {
|
|
205
|
+
const res = await recordWorkStep({ item: inboxItem(), stage: "accepted", cfg: {}, trackImpl: async () => ({}) });
|
|
206
|
+
assert.equal(res.tracked, false);
|
|
207
|
+
assert.equal(res.reason, "org-disabled");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a server error frame comes back as a REASON, never a throw", async () => {
|
|
211
|
+
const res = await recordWorkStep({
|
|
212
|
+
item: inboxItem(),
|
|
213
|
+
stage: "accepted",
|
|
214
|
+
cfg: CFG,
|
|
215
|
+
trackImpl: async () => ({ error: { code: "FORBIDDEN_SCOPE", message: "board.write not granted" } }),
|
|
216
|
+
});
|
|
217
|
+
assert.equal(res.tracked, false);
|
|
218
|
+
assert.match(res.reason, /FORBIDDEN_SCOPE/);
|
|
219
|
+
assert.match(res.reason, /board\.write not granted/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a thrown transport error is contained — the dispatch path never sees it", async () => {
|
|
223
|
+
const res = await recordWorkStep({
|
|
224
|
+
item: inboxItem(),
|
|
225
|
+
stage: "accepted",
|
|
226
|
+
cfg: CFG,
|
|
227
|
+
trackImpl: async () => {
|
|
228
|
+
throw new Error("ECONNREFUSED");
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
assert.equal(res.tracked, false);
|
|
232
|
+
assert.match(res.reason, /ECONNREFUSED/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("WORK_STAGES matches the server's ladder", () => {
|
|
236
|
+
assert.deepEqual(WORK_STAGES, ["accepted", "working", "blocked", "review", "done", "failed"]);
|
|
237
|
+
});
|