@cohortapp/agent-sdk 2.11.14 → 2.12.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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +113 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +264 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +410 -0
- package/lib/cli/global-setup-extras.test.mjs +367 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +750 -0
- package/lib/cli/session.test.mjs +602 -0
- package/lib/collective/global-config.mjs +204 -6
- package/lib/collective/global-config.test.mjs +140 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/identity/persona.mjs +29 -0
- package/lib/identity/persona.test.mjs +26 -1
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +21 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/scaffold/CLAUDE.md +34 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +141 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
- package/scripts/daemon/cadence-consumer.mjs +254 -78
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +9 -1
- package/scripts/daemon/prompt-builder.test.mjs +22 -0
- package/scripts/daemon/responder.mjs +61 -41
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +488 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CLAUDE_CONFIG_FILE,
|
|
7
|
+
HEARTBEAT_GRACE_MS,
|
|
8
|
+
seedClaudeConfig,
|
|
9
|
+
ensureClaudeConfig,
|
|
10
|
+
heartbeatSilence,
|
|
11
|
+
attentionRecord,
|
|
12
|
+
} from "./first-run.mjs";
|
|
13
|
+
|
|
14
|
+
const ROOT = "/Users/o/olivia-ai";
|
|
15
|
+
const T0 = Date.parse("2026-09-08T10:00:00Z");
|
|
16
|
+
|
|
17
|
+
test("seedClaudeConfig: an absent/empty ~/.claude.json gets both acknowledgements", () => {
|
|
18
|
+
for (const text of [undefined, null, "", " "]) {
|
|
19
|
+
const r = seedClaudeConfig(text, { agentRoot: ROOT, bypass: true });
|
|
20
|
+
assert.equal(r.ok, true);
|
|
21
|
+
assert.equal(r.changed, true);
|
|
22
|
+
assert.deepEqual(r.applied, ["bypassPermissionsModeAccepted", `projects[${ROOT}].hasTrustDialogAccepted`]);
|
|
23
|
+
assert.deepEqual(r.config, { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("seedClaudeConfig: existing keys, other projects and project fields are preserved; only the missing flags are added", () => {
|
|
28
|
+
const existing = {
|
|
29
|
+
numStartups: 40,
|
|
30
|
+
theme: "dark",
|
|
31
|
+
projects: {
|
|
32
|
+
"/Users/o/other": { hasTrustDialogAccepted: false, allowedTools: ["Bash"] },
|
|
33
|
+
[ROOT]: { allowedTools: ["Read"], hasCompletedProjectOnboarding: true },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const r = seedClaudeConfig(JSON.stringify(existing), { agentRoot: ROOT, bypass: true });
|
|
37
|
+
assert.equal(r.changed, true);
|
|
38
|
+
assert.deepEqual(r.config, {
|
|
39
|
+
numStartups: 40,
|
|
40
|
+
theme: "dark",
|
|
41
|
+
bypassPermissionsModeAccepted: true,
|
|
42
|
+
projects: {
|
|
43
|
+
"/Users/o/other": { hasTrustDialogAccepted: false, allowedTools: ["Bash"] },
|
|
44
|
+
[ROOT]: { allowedTools: ["Read"], hasCompletedProjectOnboarding: true, hasTrustDialogAccepted: true },
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("seedClaudeConfig: already seeded → unchanged (idempotent); bypass:false leaves the bypass flag alone", () => {
|
|
50
|
+
const seeded = { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } };
|
|
51
|
+
const r = seedClaudeConfig(JSON.stringify(seeded), { agentRoot: ROOT, bypass: true });
|
|
52
|
+
assert.equal(r.changed, false);
|
|
53
|
+
assert.deepEqual(r.applied, []);
|
|
54
|
+
const r2 = seedClaudeConfig("{}", { agentRoot: ROOT, bypass: false });
|
|
55
|
+
assert.deepEqual(r2.applied, [`projects[${ROOT}].hasTrustDialogAccepted`]);
|
|
56
|
+
assert.equal("bypassPermissionsModeAccepted" in r2.config, false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("seedClaudeConfig: a malformed or non-object file is NOT clobbered", () => {
|
|
60
|
+
for (const text of ["{", "[1,2]", "42", "null"]) {
|
|
61
|
+
const r = seedClaudeConfig(text, { agentRoot: ROOT, bypass: true });
|
|
62
|
+
assert.equal(r.ok, false, text);
|
|
63
|
+
assert.equal(r.changed, false);
|
|
64
|
+
assert.match(r.error, /malformed|not an object/);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("ensureClaudeConfig: writes ~/.claude.json only when something changed; never throws", () => {
|
|
69
|
+
const home = "/Users/o";
|
|
70
|
+
const file = join(home, CLAUDE_CONFIG_FILE);
|
|
71
|
+
const writes = [];
|
|
72
|
+
const deps = (files) => ({
|
|
73
|
+
readFileSync: (p) => { if (!(p in files)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; } return files[p]; },
|
|
74
|
+
writeFileAtomic: (p, data) => { writes.push([p, String(data)]); },
|
|
75
|
+
});
|
|
76
|
+
// Absent → written.
|
|
77
|
+
let r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({}) });
|
|
78
|
+
assert.equal(r.ok, true);
|
|
79
|
+
assert.equal(r.changed, true);
|
|
80
|
+
assert.equal(r.path, file);
|
|
81
|
+
assert.equal(writes.length, 1);
|
|
82
|
+
assert.deepEqual(JSON.parse(writes[0][1]), { bypassPermissionsModeAccepted: true, projects: { [ROOT]: { hasTrustDialogAccepted: true } } });
|
|
83
|
+
// Already seeded → no write.
|
|
84
|
+
r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({ [file]: writes[0][1] }) });
|
|
85
|
+
assert.equal(r.changed, false);
|
|
86
|
+
assert.equal(writes.length, 1);
|
|
87
|
+
// Malformed → reported, untouched.
|
|
88
|
+
r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, ...deps({ [file]: "{oops" }) });
|
|
89
|
+
assert.equal(r.ok, false);
|
|
90
|
+
assert.equal(writes.length, 1);
|
|
91
|
+
// A throwing writer is a result, not a throw.
|
|
92
|
+
r = ensureClaudeConfig({ homeDir: home, agentRoot: ROOT, bypass: true, readFileSync: deps({}).readFileSync, writeFileAtomic: () => { throw new Error("EROFS"); } });
|
|
93
|
+
assert.equal(r.ok, false);
|
|
94
|
+
assert.match(r.error, /EROFS/);
|
|
95
|
+
// No home → nothing to do.
|
|
96
|
+
r = ensureClaudeConfig({ homeDir: "", agentRoot: ROOT, bypass: true, ...deps({}) });
|
|
97
|
+
assert.equal(r.ok, false);
|
|
98
|
+
assert.equal(writes.length, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("heartbeatSilence: within grace → not silent; a beat since launch → beating; no/old beat past grace → silent", () => {
|
|
102
|
+
const started = T0;
|
|
103
|
+
assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 10_000 }), { silent: false, reason: "within-grace", runMs: 10_000 });
|
|
104
|
+
assert.equal(HEARTBEAT_GRACE_MS, 120_000);
|
|
105
|
+
assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 120_000 }), { silent: true, reason: "no-heartbeat", runMs: 120_000 });
|
|
106
|
+
const old = { ts: new Date(T0 - 60_000).toISOString() }; // from the PREVIOUS lifetime
|
|
107
|
+
assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: old, now: T0 + 120_000 }), { silent: true, reason: "stale-heartbeat", runMs: 120_000 });
|
|
108
|
+
const fresh = { ts: new Date(T0 + 15_000).toISOString() };
|
|
109
|
+
assert.deepEqual(heartbeatSilence({ startedAt: started, heartbeat: fresh, now: T0 + 120_000 }), { silent: false, reason: "beating", runMs: 120_000 });
|
|
110
|
+
assert.equal(heartbeatSilence({ startedAt: started, heartbeat: null, now: T0 + 30_000, graceMs: 20_000 }).silent, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("attentionRecord: what the doctor reads — reason, since, the attach command for the mux in use", () => {
|
|
114
|
+
const r = attentionRecord({ reason: "no-heartbeat", mux: { kind: "screen" }, muxName: "maestro-olivia", since: T0, runMs: 120_000 });
|
|
115
|
+
assert.equal(r.reason, "no-heartbeat");
|
|
116
|
+
assert.equal(r.since, "2026-09-08T10:00:00.000Z");
|
|
117
|
+
assert.equal(r.runMs, 120_000);
|
|
118
|
+
assert.equal(r.attach, "screen -r maestro-olivia");
|
|
119
|
+
assert.match(r.hint, /dialog|prompt/i);
|
|
120
|
+
assert.equal(attentionRecord({ reason: "no-heartbeat", mux: { kind: "tmux" }, muxName: "maestro-olivia", since: T0, runMs: 1 }).attach, "tmux attach -t =maestro-olivia");
|
|
121
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/frontdoor.mjs — WHO answers an inbound event: the main session
|
|
3
|
+
* or the daemon's legacy `claude --print` lane (design §3.1, §3.3, §3.4).
|
|
4
|
+
*
|
|
5
|
+
* THE ONE RULE. When the seat's main session is the configured front door AND
|
|
6
|
+
* it is provably live (a fresh `state/session/heartbeat.json`), the daemon
|
|
7
|
+
* stops spawning for the surfaces the session owns: Cohort inbox items are
|
|
8
|
+
* left in place for the session to claim, and escalate/guarded cadence ticks
|
|
9
|
+
* are written as handoffs for the session to ack. When the session is NOT
|
|
10
|
+
* live — no heartbeat, a stale one, or the front door is `daemon` — the daemon
|
|
11
|
+
* dispatches exactly as it always has. Liveness is MEASURED, never assumed,
|
|
12
|
+
* and the fallback is the legacy lane, so nothing is ever dropped.
|
|
13
|
+
*
|
|
14
|
+
* PURE CORE. `sessionLiveFromHeartbeat`, `resolveFrontDoor`,
|
|
15
|
+
* `frontDoorServices`, `shouldDaemonDispatch` and `shouldHandOffTick` take
|
|
16
|
+
* every input as a parameter — the parsed heartbeat object, the clock, the
|
|
17
|
+
* mode, the tick's mode/metadata. `readFrontDoorState` is the ONE edge reader
|
|
18
|
+
* (config/session.yaml + state/session/heartbeat.json) and it takes its fs /
|
|
19
|
+
* env / clock injected so a call site can be tested without a disk.
|
|
20
|
+
*
|
|
21
|
+
* NOTE: `lib/session/{config,liveness}.mjs` (WP-M1) own the canonical session
|
|
22
|
+
* config + heartbeat readers once they land; the readers here are deliberately
|
|
23
|
+
* minimal and shaped to be swapped for those without touching the callers.
|
|
24
|
+
*
|
|
25
|
+
* @module lib/session/frontdoor
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import * as nodeFs from "node:fs";
|
|
30
|
+
import { createRequire } from "node:module";
|
|
31
|
+
|
|
32
|
+
// js-yaml is a declared dependency; loaded synchronously so the reader stays
|
|
33
|
+
// sync. Fail-open: without it the flat line parser below still reads the
|
|
34
|
+
// one-level form (`frontDoor: daemon`), and every nested form is a no-op.
|
|
35
|
+
let defaultYamlParse = null;
|
|
36
|
+
try {
|
|
37
|
+
const y = createRequire(import.meta.url)("js-yaml");
|
|
38
|
+
if (y && typeof y.load === "function") defaultYamlParse = (text) => y.load(text);
|
|
39
|
+
} catch { defaultYamlParse = null; }
|
|
40
|
+
|
|
41
|
+
/** A heartbeat older than this is stale — the session is treated as not live. */
|
|
42
|
+
export const DEFAULT_STALE_MS = 90_000;
|
|
43
|
+
/** Inbox services the session fronts by default. */
|
|
44
|
+
export const DEFAULT_FRONT_DOOR_SERVICES = Object.freeze(["cohort"]);
|
|
45
|
+
/** Cadence modes whose tick spawns a sub-session — the ones a live session takes over. */
|
|
46
|
+
export const HANDOFF_MODES = Object.freeze(["escalate", "guarded"]);
|
|
47
|
+
/** Where the session runtime writes its heartbeat (relative to the agent root). */
|
|
48
|
+
export const HEARTBEAT_RELATIVE = "state/session/heartbeat.json";
|
|
49
|
+
/** Session runtime config (relative to the agent root). */
|
|
50
|
+
export const SESSION_CONFIG_RELATIVE = "config/session.yaml";
|
|
51
|
+
|
|
52
|
+
const MODES = new Set(["session", "daemon"]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Is the main session live, given its parsed heartbeat? Pure.
|
|
56
|
+
*
|
|
57
|
+
* @param {object|null} heartbeat parsed `state/session/heartbeat.json` ({ts,...}) or null
|
|
58
|
+
* @param {object} [o]
|
|
59
|
+
* @param {number} [o.now] epoch ms
|
|
60
|
+
* @param {number} [o.staleMs] freshness window (default DEFAULT_STALE_MS)
|
|
61
|
+
* @returns {{live:boolean, reason:string, ageMs:number|null}}
|
|
62
|
+
*/
|
|
63
|
+
export function sessionLiveFromHeartbeat(heartbeat, o = {}) {
|
|
64
|
+
const now = typeof o.now === "number" ? o.now : Date.now();
|
|
65
|
+
const staleMs = Number.isFinite(o.staleMs) && o.staleMs > 0 ? o.staleMs : DEFAULT_STALE_MS;
|
|
66
|
+
if (!heartbeat || typeof heartbeat !== "object") return { live: false, reason: "no-heartbeat", ageMs: null };
|
|
67
|
+
const raw = heartbeat.ts;
|
|
68
|
+
const ts = typeof raw === "number" ? raw : (typeof raw === "string" ? Date.parse(raw) : NaN);
|
|
69
|
+
if (!Number.isFinite(ts)) return { live: false, reason: "bad-ts", ageMs: null };
|
|
70
|
+
const ageMs = now - ts;
|
|
71
|
+
// A slightly future stamp is writer/reader clock skew, not a dead session;
|
|
72
|
+
// a stamp further ahead than the window is a broken clock — not trusted.
|
|
73
|
+
if (ageMs < -staleMs) return { live: false, reason: "future-ts", ageMs };
|
|
74
|
+
if (ageMs > staleMs) return { live: false, reason: "stale", ageMs };
|
|
75
|
+
return { live: true, reason: "fresh", ageMs };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Which process is the front door. `MAESTRO_FRONT_DOOR` in the env wins, then
|
|
80
|
+
* `frontDoor` / `front_door` in config/session.yaml (a string, or an object
|
|
81
|
+
* with `.mode`), else `session`. An unknown value never selects a mode.
|
|
82
|
+
*
|
|
83
|
+
* @param {{env?:object, config?:object|null}} [o]
|
|
84
|
+
* @returns {"session"|"daemon"}
|
|
85
|
+
*/
|
|
86
|
+
export function resolveFrontDoor(o = {}) {
|
|
87
|
+
const env = o.env && typeof o.env === "object" ? o.env : {};
|
|
88
|
+
const fromEnv = String(env.MAESTRO_FRONT_DOOR || "").trim().toLowerCase();
|
|
89
|
+
if (MODES.has(fromEnv)) return fromEnv;
|
|
90
|
+
const cfg = o.config && typeof o.config === "object" ? o.config : {};
|
|
91
|
+
const raw = cfg.frontDoor !== undefined ? cfg.frontDoor : cfg.front_door;
|
|
92
|
+
const val = raw && typeof raw === "object" ? raw.mode : raw;
|
|
93
|
+
const fromCfg = String(val || "").trim().toLowerCase();
|
|
94
|
+
if (MODES.has(fromCfg)) return fromCfg;
|
|
95
|
+
return "session";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The inbox services the session fronts. `frontDoor.services` or a top-level
|
|
100
|
+
* `services` list in config/session.yaml; default `["cohort"]`.
|
|
101
|
+
*
|
|
102
|
+
* @param {object|null} config
|
|
103
|
+
* @returns {string[]}
|
|
104
|
+
*/
|
|
105
|
+
export function frontDoorServices(config) {
|
|
106
|
+
const cfg = config && typeof config === "object" ? config : {};
|
|
107
|
+
const fd = cfg.frontDoor && typeof cfg.frontDoor === "object" ? cfg.frontDoor : null;
|
|
108
|
+
const list = fd && Array.isArray(fd.services) ? fd.services : (Array.isArray(cfg.services) ? cfg.services : null);
|
|
109
|
+
if (!list) return [...DEFAULT_FRONT_DOOR_SERVICES];
|
|
110
|
+
const out = list.map((s) => String(s || "").trim().toLowerCase()).filter(Boolean);
|
|
111
|
+
return out.length ? out : [...DEFAULT_FRONT_DOOR_SERVICES];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Should the DAEMON dispatch an inbox item from `service`? Pure.
|
|
116
|
+
* `dispatch:false` means "leave the item in place — the live session owns it".
|
|
117
|
+
* Any malformed input resolves to dispatch (fail-open: never drop an item).
|
|
118
|
+
*
|
|
119
|
+
* @param {{frontDoor?:string, sessionLive?:boolean, service?:string, services?:string[]}} a
|
|
120
|
+
* @returns {{dispatch:boolean, reason:string}}
|
|
121
|
+
*/
|
|
122
|
+
export function shouldDaemonDispatch(a) {
|
|
123
|
+
const x = a && typeof a === "object" ? a : {};
|
|
124
|
+
if (x.frontDoor !== "session") return { dispatch: true, reason: "front-door-daemon" };
|
|
125
|
+
if (x.sessionLive !== true) return { dispatch: true, reason: "session-not-live" };
|
|
126
|
+
const services = Array.isArray(x.services) && x.services.length ? x.services : DEFAULT_FRONT_DOOR_SERVICES;
|
|
127
|
+
const svc = String(x.service || "").trim().toLowerCase();
|
|
128
|
+
if (!svc || !services.includes(svc)) return { dispatch: true, reason: "service-not-front-door" };
|
|
129
|
+
return { dispatch: false, reason: "session-live" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Should the cadence consumer HAND a tick to the live session instead of
|
|
134
|
+
* spawning a sub-session? Pure. Only escalate/guarded ticks (the ones that
|
|
135
|
+
* spawn) qualify; a tick whose previous handoff timed out is never handed off
|
|
136
|
+
* again (`metadata.handoffTimedOut`), so a wedged session cannot starve a
|
|
137
|
+
* cadence. An absent mode means "unknown cadence" — the consumer treats those
|
|
138
|
+
* as escalate, so they qualify too.
|
|
139
|
+
*
|
|
140
|
+
* @param {{frontDoor?:string, sessionLive?:boolean, mode?:string, metadata?:object}} a
|
|
141
|
+
* @returns {{handOff:boolean, reason:string}}
|
|
142
|
+
*/
|
|
143
|
+
export function shouldHandOffTick(a) {
|
|
144
|
+
const x = a && typeof a === "object" ? a : {};
|
|
145
|
+
if (x.frontDoor !== "session") return { handOff: false, reason: "front-door-daemon" };
|
|
146
|
+
if (x.sessionLive !== true) return { handOff: false, reason: "session-not-live" };
|
|
147
|
+
const mode = x.mode == null ? "escalate" : String(x.mode);
|
|
148
|
+
if (!HANDOFF_MODES.includes(mode)) return { handOff: false, reason: "mode-not-handoff" };
|
|
149
|
+
if (x.metadata && typeof x.metadata === "object" && x.metadata.handoffTimedOut === true) {
|
|
150
|
+
return { handOff: false, reason: "handoff-timed-out" };
|
|
151
|
+
}
|
|
152
|
+
return { handOff: true, reason: "hand-off" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* YAML reader for config/session.yaml: the injected parser, else js-yaml
|
|
157
|
+
* (the documented nested `frontDoor: {mode, services}` shape needs a real
|
|
158
|
+
* parser), else a flat `key: value` / `key: [a, b]` line parser as the last
|
|
159
|
+
* resort. Never throws — returns null on any failure.
|
|
160
|
+
*/
|
|
161
|
+
function parseSessionYaml(text, yamlParse) {
|
|
162
|
+
const parse = typeof yamlParse === "function" ? yamlParse : defaultYamlParse;
|
|
163
|
+
if (parse) {
|
|
164
|
+
try { const v = parse(text); return v && typeof v === "object" ? v : null; } catch { return null; }
|
|
165
|
+
}
|
|
166
|
+
const out = {};
|
|
167
|
+
try {
|
|
168
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
169
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/);
|
|
170
|
+
if (!m) continue;
|
|
171
|
+
const [, k, v] = m;
|
|
172
|
+
if (/^\[.*\]$/.test(v)) out[k] = v.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
173
|
+
else if (v !== "") out[k] = v.replace(/^["']|["']$/g, "");
|
|
174
|
+
}
|
|
175
|
+
} catch { return null; }
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* THE EDGE READER. Reads config/session.yaml and state/session/heartbeat.json
|
|
181
|
+
* under `agentRoot` and folds them into the decision inputs. Every failure is
|
|
182
|
+
* fail-open: an unreadable config keeps the env/default mode, an unreadable or
|
|
183
|
+
* corrupt heartbeat means "not live" — i.e. the legacy daemon lane runs.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} agentRoot
|
|
186
|
+
* @param {object} [deps]
|
|
187
|
+
* @param {object} [deps.fs] `{existsSync, readFileSync}` (default node:fs)
|
|
188
|
+
* @param {object} [deps.env] (default process.env)
|
|
189
|
+
* @param {number} [deps.now] epoch ms
|
|
190
|
+
* @param {number} [deps.staleMs]
|
|
191
|
+
* @param {Function} [deps.yamlParse] optional YAML parser (js-yaml `load`)
|
|
192
|
+
* @returns {{frontDoor:"session"|"daemon", sessionLive:boolean, services:string[], liveness:object, heartbeat:object|null}}
|
|
193
|
+
*/
|
|
194
|
+
export function readFrontDoorState(agentRoot, deps = {}) {
|
|
195
|
+
const fs = deps.fs || nodeFs;
|
|
196
|
+
const env = deps.env || process.env;
|
|
197
|
+
const now = typeof deps.now === "number" ? deps.now : Date.now();
|
|
198
|
+
let config = null;
|
|
199
|
+
try {
|
|
200
|
+
const p = join(agentRoot, SESSION_CONFIG_RELATIVE);
|
|
201
|
+
if (fs.existsSync(p)) config = parseSessionYaml(fs.readFileSync(p, "utf-8"), deps.yamlParse);
|
|
202
|
+
} catch { config = null; /* unreadable config → env/default mode (fail-open) */ }
|
|
203
|
+
let heartbeat = null;
|
|
204
|
+
try {
|
|
205
|
+
const p = join(agentRoot, HEARTBEAT_RELATIVE);
|
|
206
|
+
if (fs.existsSync(p)) {
|
|
207
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
208
|
+
heartbeat = parsed && typeof parsed === "object" ? parsed : null;
|
|
209
|
+
}
|
|
210
|
+
} catch { heartbeat = null; /* corrupt heartbeat → not live → legacy lane (fail-open) */ }
|
|
211
|
+
const liveness = sessionLiveFromHeartbeat(heartbeat, { now, staleMs: deps.staleMs });
|
|
212
|
+
return {
|
|
213
|
+
frontDoor: resolveFrontDoor({ env, config }),
|
|
214
|
+
sessionLive: liveness.live,
|
|
215
|
+
services: frontDoorServices(config),
|
|
216
|
+
liveness,
|
|
217
|
+
heartbeat,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A small stateful gate for a poll loop: `check(service)` re-reads the front
|
|
223
|
+
* door state and returns the dispatch verdict plus `changed` — true only when
|
|
224
|
+
* the verdict for that service differs from the last one, so a 2-second poll
|
|
225
|
+
* can log transitions without logging every tick. A throwing reader fails
|
|
226
|
+
* open to dispatch.
|
|
227
|
+
*
|
|
228
|
+
* @param {{agentRoot:string, readState?:Function, now?:Function}} o
|
|
229
|
+
* @returns {{check:(service:string)=>{dispatch:boolean, reason:string, changed:boolean, state:object|null}}}
|
|
230
|
+
*/
|
|
231
|
+
export function makeFrontDoorGate(o = {}) {
|
|
232
|
+
const readState = typeof o.readState === "function" ? o.readState : readFrontDoorState;
|
|
233
|
+
const nowFn = typeof o.now === "function" ? o.now : Date.now;
|
|
234
|
+
// Last verdict key per service — the only state, and only for log hygiene.
|
|
235
|
+
const last = new Map();
|
|
236
|
+
return {
|
|
237
|
+
check(service) {
|
|
238
|
+
let state = null;
|
|
239
|
+
let verdict;
|
|
240
|
+
try {
|
|
241
|
+
state = readState(o.agentRoot, { now: nowFn() });
|
|
242
|
+
verdict = shouldDaemonDispatch({ ...state, service });
|
|
243
|
+
} catch {
|
|
244
|
+
// A broken reader must never stall intake: dispatch on the legacy lane.
|
|
245
|
+
verdict = { dispatch: true, reason: "reader-error" };
|
|
246
|
+
}
|
|
247
|
+
const key = `${verdict.dispatch}:${verdict.reason}`;
|
|
248
|
+
const changed = last.get(service) !== key;
|
|
249
|
+
last.set(service, key);
|
|
250
|
+
return { ...verdict, changed, state };
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default {
|
|
256
|
+
DEFAULT_STALE_MS,
|
|
257
|
+
DEFAULT_FRONT_DOOR_SERVICES,
|
|
258
|
+
HANDOFF_MODES,
|
|
259
|
+
sessionLiveFromHeartbeat,
|
|
260
|
+
resolveFrontDoor,
|
|
261
|
+
frontDoorServices,
|
|
262
|
+
shouldDaemonDispatch,
|
|
263
|
+
shouldHandOffTick,
|
|
264
|
+
readFrontDoorState,
|
|
265
|
+
makeFrontDoorGate,
|
|
266
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frontdoor.test.mjs — the pure front-door decision core (design §3.1, §3.4).
|
|
3
|
+
*
|
|
4
|
+
* Every function here is a pure decision over injected inputs: the parsed
|
|
5
|
+
* heartbeat, a clock, the front-door mode and the tick shape. No disk, no
|
|
6
|
+
* env reads inside the core — `readFrontDoorState` is the one edge reader and
|
|
7
|
+
* it takes its fs/env/clock as parameters so the tests are hermetic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_STALE_MS,
|
|
15
|
+
DEFAULT_FRONT_DOOR_SERVICES,
|
|
16
|
+
sessionLiveFromHeartbeat,
|
|
17
|
+
resolveFrontDoor,
|
|
18
|
+
frontDoorServices,
|
|
19
|
+
shouldDaemonDispatch,
|
|
20
|
+
shouldHandOffTick,
|
|
21
|
+
readFrontDoorState,
|
|
22
|
+
makeFrontDoorGate,
|
|
23
|
+
} from "./frontdoor.mjs";
|
|
24
|
+
|
|
25
|
+
const T0 = Date.parse("2026-09-08T10:00:00.000Z");
|
|
26
|
+
|
|
27
|
+
// ── sessionLiveFromHeartbeat ─────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
test("liveness: a fresh heartbeat is live; a stale one is not; none is not", () => {
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
sessionLiveFromHeartbeat({ pid: 1, ts: new Date(T0 - 10_000).toISOString() }, { now: T0 }),
|
|
32
|
+
{ live: true, reason: "fresh", ageMs: 10_000 },
|
|
33
|
+
);
|
|
34
|
+
const stale = sessionLiveFromHeartbeat({ ts: new Date(T0 - DEFAULT_STALE_MS - 1).toISOString() }, { now: T0 });
|
|
35
|
+
assert.equal(stale.live, false);
|
|
36
|
+
assert.equal(stale.reason, "stale");
|
|
37
|
+
assert.equal(sessionLiveFromHeartbeat(null, { now: T0 }).live, false);
|
|
38
|
+
assert.equal(sessionLiveFromHeartbeat(null, { now: T0 }).reason, "no-heartbeat");
|
|
39
|
+
assert.equal(sessionLiveFromHeartbeat({ ts: "garbage" }, { now: T0 }).reason, "bad-ts");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("liveness: accepts epoch-ms ts, honours a custom staleMs, tolerates slight clock skew", () => {
|
|
43
|
+
assert.equal(sessionLiveFromHeartbeat({ ts: T0 - 5_000 }, { now: T0 }).live, true);
|
|
44
|
+
assert.equal(sessionLiveFromHeartbeat({ ts: T0 - 5_000 }, { now: T0, staleMs: 4_000 }).live, false);
|
|
45
|
+
// A heartbeat a second "in the future" (writer/reader clock skew) is fresh.
|
|
46
|
+
assert.equal(sessionLiveFromHeartbeat({ ts: T0 + 1_000 }, { now: T0 }).live, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── resolveFrontDoor / frontDoorServices ─────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
test("resolveFrontDoor: env wins over config; config string or object; default session", () => {
|
|
52
|
+
assert.equal(resolveFrontDoor(), "session");
|
|
53
|
+
assert.equal(resolveFrontDoor({ config: { frontDoor: "daemon" } }), "daemon");
|
|
54
|
+
assert.equal(resolveFrontDoor({ config: { front_door: "daemon" } }), "daemon");
|
|
55
|
+
assert.equal(resolveFrontDoor({ config: { frontDoor: { mode: "daemon" } } }), "daemon");
|
|
56
|
+
assert.equal(resolveFrontDoor({ env: { MAESTRO_FRONT_DOOR: "daemon" }, config: { frontDoor: "session" } }), "daemon");
|
|
57
|
+
// An unknown value never selects an unknown mode.
|
|
58
|
+
assert.equal(resolveFrontDoor({ env: { MAESTRO_FRONT_DOOR: "banana" } }), "session");
|
|
59
|
+
assert.equal(resolveFrontDoor({ config: { frontDoor: "banana" } }), "session");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("frontDoorServices: default cohort; config may widen; junk is ignored", () => {
|
|
63
|
+
assert.deepEqual(frontDoorServices(null), [...DEFAULT_FRONT_DOOR_SERVICES]);
|
|
64
|
+
assert.deepEqual(frontDoorServices({ frontDoor: { services: ["cohort", "slack"] } }), ["cohort", "slack"]);
|
|
65
|
+
assert.deepEqual(frontDoorServices({ services: ["orgmail"] }), ["orgmail"]);
|
|
66
|
+
assert.deepEqual(frontDoorServices({ services: "nope" }), [...DEFAULT_FRONT_DOOR_SERVICES]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── shouldDaemonDispatch ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
test("shouldDaemonDispatch: the daemon steps back only when the session is the live front door for that service", () => {
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "cohort" }),
|
|
74
|
+
{ dispatch: false, reason: "session-live" },
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
shouldDaemonDispatch({ frontDoor: "session", sessionLive: false, service: "cohort" }),
|
|
78
|
+
{ dispatch: true, reason: "session-not-live" },
|
|
79
|
+
);
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
shouldDaemonDispatch({ frontDoor: "daemon", sessionLive: true, service: "cohort" }),
|
|
82
|
+
{ dispatch: true, reason: "front-door-daemon" },
|
|
83
|
+
);
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "slack" }),
|
|
86
|
+
{ dispatch: true, reason: "service-not-front-door" },
|
|
87
|
+
);
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
shouldDaemonDispatch({ frontDoor: "session", sessionLive: true, service: "slack", services: ["cohort", "slack"] }),
|
|
90
|
+
{ dispatch: false, reason: "session-live" },
|
|
91
|
+
);
|
|
92
|
+
// Garbage in → legacy dispatch (fail-open: nothing is ever dropped).
|
|
93
|
+
assert.equal(shouldDaemonDispatch({}).dispatch, true);
|
|
94
|
+
assert.equal(shouldDaemonDispatch(null).dispatch, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── shouldHandOffTick ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test("shouldHandOffTick: escalate/guarded ticks hand off to a live session; inline never; timed-out never", () => {
|
|
100
|
+
const live = { frontDoor: "session", sessionLive: true };
|
|
101
|
+
assert.deepEqual(shouldHandOffTick({ ...live, mode: "escalate" }), { handOff: true, reason: "hand-off" });
|
|
102
|
+
assert.deepEqual(shouldHandOffTick({ ...live, mode: "guarded" }), { handOff: true, reason: "hand-off" });
|
|
103
|
+
assert.deepEqual(shouldHandOffTick({ ...live, mode: "inline" }), { handOff: false, reason: "mode-not-handoff" });
|
|
104
|
+
// An unknown cadence (no registry entry) defaults to escalate in the consumer.
|
|
105
|
+
assert.deepEqual(shouldHandOffTick({ ...live, mode: undefined }), { handOff: true, reason: "hand-off" });
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
shouldHandOffTick({ ...live, mode: "escalate", metadata: { handoffTimedOut: true } }),
|
|
108
|
+
{ handOff: false, reason: "handoff-timed-out" },
|
|
109
|
+
);
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
shouldHandOffTick({ frontDoor: "session", sessionLive: false, mode: "escalate" }),
|
|
112
|
+
{ handOff: false, reason: "session-not-live" },
|
|
113
|
+
);
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
shouldHandOffTick({ frontDoor: "daemon", sessionLive: true, mode: "escalate" }),
|
|
116
|
+
{ handOff: false, reason: "front-door-daemon" },
|
|
117
|
+
);
|
|
118
|
+
assert.equal(shouldHandOffTick(null).handOff, false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ── readFrontDoorState (edge reader, injected fs/env/clock) ──────────────────
|
|
122
|
+
|
|
123
|
+
function fakeFs(files) {
|
|
124
|
+
return {
|
|
125
|
+
existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p),
|
|
126
|
+
readFileSync: (p) => {
|
|
127
|
+
if (!Object.prototype.hasOwnProperty.call(files, p)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; }
|
|
128
|
+
return files[p];
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("readFrontDoorState: reads config/session.yaml + state/session/heartbeat.json through injected fs", () => {
|
|
134
|
+
const root = "/agent";
|
|
135
|
+
const fs = fakeFs({
|
|
136
|
+
"/agent/config/session.yaml": "frontDoor: session\nservices: [cohort, slack]\n",
|
|
137
|
+
"/agent/state/session/heartbeat.json": JSON.stringify({ pid: 4, ts: new Date(T0 - 1000).toISOString() }),
|
|
138
|
+
});
|
|
139
|
+
const st = readFrontDoorState(root, { fs, env: {}, now: T0 });
|
|
140
|
+
assert.equal(st.frontDoor, "session");
|
|
141
|
+
assert.equal(st.sessionLive, true);
|
|
142
|
+
assert.deepEqual(st.services, ["cohort", "slack"]);
|
|
143
|
+
assert.equal(st.liveness.reason, "fresh");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("readFrontDoorState: no config + no heartbeat → session mode, not live (legacy dispatch); env override honoured", () => {
|
|
147
|
+
const st = readFrontDoorState("/agent", { fs: fakeFs({}), env: {}, now: T0 });
|
|
148
|
+
assert.equal(st.frontDoor, "session");
|
|
149
|
+
assert.equal(st.sessionLive, false);
|
|
150
|
+
assert.deepEqual(st.services, ["cohort"]);
|
|
151
|
+
const st2 = readFrontDoorState("/agent", { fs: fakeFs({}), env: { MAESTRO_FRONT_DOOR: "daemon" }, now: T0 });
|
|
152
|
+
assert.equal(st2.frontDoor, "daemon");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("readFrontDoorState: a corrupt heartbeat or config never throws — fails open to not-live", () => {
|
|
156
|
+
const fs = fakeFs({
|
|
157
|
+
"/agent/config/session.yaml": ": : not yaml [[[",
|
|
158
|
+
"/agent/state/session/heartbeat.json": "{not json",
|
|
159
|
+
});
|
|
160
|
+
const st = readFrontDoorState("/agent", { fs, env: {}, now: T0 });
|
|
161
|
+
assert.equal(st.sessionLive, false);
|
|
162
|
+
assert.equal(st.frontDoor, "session");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── makeFrontDoorGate ────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
test("makeFrontDoorGate: check() reports the decision and flags transitions only once", () => {
|
|
168
|
+
let live = true;
|
|
169
|
+
const gate = makeFrontDoorGate({
|
|
170
|
+
agentRoot: "/agent",
|
|
171
|
+
readState: () => ({ frontDoor: "session", sessionLive: live, services: ["cohort"] }),
|
|
172
|
+
});
|
|
173
|
+
const a = gate.check("cohort");
|
|
174
|
+
assert.equal(a.dispatch, false);
|
|
175
|
+
assert.equal(a.changed, true, "first observation is a transition");
|
|
176
|
+
const b = gate.check("cohort");
|
|
177
|
+
assert.equal(b.changed, false, "same verdict again is not a transition");
|
|
178
|
+
live = false;
|
|
179
|
+
const c = gate.check("cohort");
|
|
180
|
+
assert.equal(c.dispatch, true);
|
|
181
|
+
assert.equal(c.changed, true, "liveness flipped → transition");
|
|
182
|
+
// A reader that throws fails open to dispatch.
|
|
183
|
+
const broken = makeFrontDoorGate({ agentRoot: "/agent", readState: () => { throw new Error("boom"); } });
|
|
184
|
+
assert.equal(broken.check("cohort").dispatch, true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("readFrontDoorState: the documented NESTED config (frontDoor.mode / frontDoor.services) and the flow form are read without an injected parser", () => {
|
|
188
|
+
const nested = readFrontDoorState("/agent", {
|
|
189
|
+
fs: fakeFs({ "/agent/config/session.yaml": "frontDoor:\n mode: daemon\n services: [cohort, slack]\n" }),
|
|
190
|
+
env: {}, now: T0,
|
|
191
|
+
});
|
|
192
|
+
assert.equal(nested.frontDoor, "daemon", "an operator opting a seat out via the nested form must be honoured");
|
|
193
|
+
assert.deepEqual(nested.services, ["cohort", "slack"]);
|
|
194
|
+
const flow = readFrontDoorState("/agent", {
|
|
195
|
+
fs: fakeFs({ "/agent/config/session.yaml": "frontDoor: {mode: daemon}\n" }),
|
|
196
|
+
env: {}, now: T0,
|
|
197
|
+
});
|
|
198
|
+
assert.equal(flow.frontDoor, "daemon");
|
|
199
|
+
const block = readFrontDoorState("/agent", {
|
|
200
|
+
fs: fakeFs({ "/agent/config/session.yaml": "# session runtime\nfrontDoor:\n mode: session\n services:\n - cohort\n - orgmail\n" }),
|
|
201
|
+
env: {}, now: T0,
|
|
202
|
+
});
|
|
203
|
+
assert.equal(block.frontDoor, "session");
|
|
204
|
+
assert.deepEqual(block.services, ["cohort", "orgmail"]);
|
|
205
|
+
});
|