sentinelayer-cli 0.46.5 → 0.48.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/README.md +47 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +32 -1
- package/src/auth/session-store.js +37 -0
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +516 -30
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +25 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +110 -15
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
function clip(s, n) {
|
|
6
|
+
const t = String(s);
|
|
7
|
+
return t.length <= n ? t : t.slice(0, n) + ` …[CLIPPED +${t.length - n} chars — READ FULL BEFORE ACTING/QUOTING]`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Shared by the listener and its offline self-tests. Each instance owns its state.
|
|
11
|
+
function createClassifier({
|
|
12
|
+
me: ME, cwd: CWD, fs = require("fs"), now = Date.now,
|
|
13
|
+
setTimeout = global.setTimeout, clearTimeout = global.clearTimeout, log = console.log,
|
|
14
|
+
}) {
|
|
15
|
+
const RECEIPT = CWD + "/last-delivered.json";
|
|
16
|
+
let deliveredMaxTs = "";
|
|
17
|
+
let observedMaxTs = "";
|
|
18
|
+
let hadReceiptAtBoot = false;
|
|
19
|
+
try {
|
|
20
|
+
const prior = JSON.parse(fs.readFileSync(RECEIPT, "utf8"));
|
|
21
|
+
if (prior && prior.deliveredMaxTs) { deliveredMaxTs = String(prior.deliveredMaxTs); hadReceiptAtBoot = true; }
|
|
22
|
+
if (prior && prior.observedMaxTs) observedMaxTs = String(prior.observedMaxTs);
|
|
23
|
+
} catch {}
|
|
24
|
+
function tsMs(ts) { const ms = Date.parse(String(ts || "")); return Number.isNaN(ms) ? -1 : ms; }
|
|
25
|
+
function newerThanDelivered(ts) { return tsMs(ts) > tsMs(deliveredMaxTs); }
|
|
26
|
+
let lastStateWrite = 0;
|
|
27
|
+
let stateTimer = null;
|
|
28
|
+
function writeState(force) {
|
|
29
|
+
const currentTime = now();
|
|
30
|
+
if (!force && currentTime - lastStateWrite < 5000) {
|
|
31
|
+
if (!stateTimer) stateTimer = setTimeout(() => { stateTimer = null; writeState(true); }, 5000);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (stateTimer) { clearTimeout(stateTimer); stateTimer = null; }
|
|
35
|
+
lastStateWrite = currentTime;
|
|
36
|
+
const pending = broadcastBuf.length ? broadcastBuf : [];
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(RECEIPT, JSON.stringify({
|
|
39
|
+
deliveredMaxTs, observedMaxTs,
|
|
40
|
+
pendingCount: pending.length,
|
|
41
|
+
oldestPendingTs: pending.length ? pending[0].ts : null,
|
|
42
|
+
wroteAt: new Date(now()).toISOString(),
|
|
43
|
+
}));
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
function observe(ts) {
|
|
47
|
+
const v = String(ts || "");
|
|
48
|
+
if (tsMs(v) <= tsMs(observedMaxTs)) return;
|
|
49
|
+
observedMaxTs = v;
|
|
50
|
+
writeState(false);
|
|
51
|
+
}
|
|
52
|
+
function receipt(ts) {
|
|
53
|
+
const v = String(ts || "");
|
|
54
|
+
if (!newerThanDelivered(v)) return;
|
|
55
|
+
deliveredMaxTs = v;
|
|
56
|
+
if (tsMs(v) > tsMs(observedMaxTs)) observedMaxTs = v;
|
|
57
|
+
writeState(true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BROADCAST_FLUSH_MS = 180000;
|
|
61
|
+
const ADDRESSED_MAX_MENTIONS = 2;
|
|
62
|
+
let broadcastBuf = [];
|
|
63
|
+
let broadcastTimer = null;
|
|
64
|
+
const mySeqs = new Set();
|
|
65
|
+
|
|
66
|
+
function rememberMine(seq) {
|
|
67
|
+
if (!seq) return;
|
|
68
|
+
mySeqs.add(String(seq));
|
|
69
|
+
if (mySeqs.size > 200) mySeqs.delete(mySeqs.values().next().value);
|
|
70
|
+
}
|
|
71
|
+
function repliesToMe(target) { return !!target && mySeqs.has(String(target)); }
|
|
72
|
+
function mentionCount(text) {
|
|
73
|
+
const m = String(text || "").match(/@[A-Za-z][A-Za-z0-9._-]{2,}/g);
|
|
74
|
+
return m ? new Set(m.map((x) => x.toLowerCase())).size : 0;
|
|
75
|
+
}
|
|
76
|
+
function isHuman(who) { return /^human([-_]|$)/i.test(String(who || "")); }
|
|
77
|
+
function addressedToMe(text) {
|
|
78
|
+
return String(text || "").toLowerCase().includes(ME.toLowerCase())
|
|
79
|
+
&& mentionCount(text) <= ADDRESSED_MAX_MENTIONS;
|
|
80
|
+
}
|
|
81
|
+
function flushBroadcasts() {
|
|
82
|
+
broadcastTimer = null;
|
|
83
|
+
if (!broadcastBuf.length) return;
|
|
84
|
+
const items = broadcastBuf; broadcastBuf = [];
|
|
85
|
+
const senders = [...new Set(items.map((i) => i.who))].join(",");
|
|
86
|
+
const manifest = items.map((i) => `${i.who}@${String(i.ts || "").slice(11, 19)}`).slice(0, 14).join(" ");
|
|
87
|
+
const newest = items[items.length - 1];
|
|
88
|
+
log(`[room-digest] ${items.length} broadcast(s) from ${senders} in ${BROADCAST_FLUSH_MS / 1000}s; newest ${newest.who} ${newest.ts} :: ${clip(newest.text, 500)} — READ THE ROOM for the others | batched: ${manifest}${items.length > 14 ? " +" + (items.length - 14) + " more" : ""}`);
|
|
89
|
+
receipt(newest.ts);
|
|
90
|
+
}
|
|
91
|
+
const deliveredKeys = new Set();
|
|
92
|
+
function normTs(ts) {
|
|
93
|
+
const ms = Date.parse(String(ts || ""));
|
|
94
|
+
return Number.isNaN(ms) ? String(ts || "") : String(ms);
|
|
95
|
+
}
|
|
96
|
+
function alreadyDelivered(who, ts) {
|
|
97
|
+
const k = String(who) + "|" + normTs(ts);
|
|
98
|
+
if (deliveredKeys.has(k)) return true;
|
|
99
|
+
deliveredKeys.add(k);
|
|
100
|
+
if (deliveredKeys.size > 500) deliveredKeys.delete(deliveredKeys.values().next().value);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deliver(kind, who, ts, seq, target, text) {
|
|
105
|
+
if (alreadyDelivered(who, ts)) return;
|
|
106
|
+
if (isHuman(who) || addressedToMe(text) || repliesToMe(target)) {
|
|
107
|
+
log(`[${kind}] seq=${seq ?? "-"} ${who} ${ts || ""} replyTo=${target || "-"} :: ${clip(text, 700)}`);
|
|
108
|
+
receipt(ts);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
broadcastBuf.push({ who, ts, text });
|
|
112
|
+
if (!broadcastTimer) broadcastTimer = setTimeout(flushBroadcasts, BROADCAST_FLUSH_MS);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
deliver, addressedToMe, isHuman, mentionCount, rememberMine, flushBroadcasts,
|
|
117
|
+
tsMs, newerThanDelivered, receipt, observe, writeState,
|
|
118
|
+
bufLen: () => broadcastBuf.length,
|
|
119
|
+
state: () => ({ deliveredMaxTs, observedMaxTs, hadReceiptAtBoot }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function main() {
|
|
124
|
+
const ME = process.env.SENTI_AGENT_ID;
|
|
125
|
+
const SID = process.env.SENTI_SESSION_ID;
|
|
126
|
+
const CLI = process.env.SENTI_CLI_PATH;
|
|
127
|
+
if (!ME || !SID || !CLI || !/^[a-zA-Z0-9_-]+$/.test(ME) || !/^[a-zA-Z0-9_-]+$/.test(SID)) {
|
|
128
|
+
console.error("Set SENTI_AGENT_ID, SENTI_SESSION_ID and SENTI_CLI_PATH before starting the listener.");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
if (!path.isAbsolute(CLI)) {
|
|
132
|
+
console.error("SENTI_CLI_PATH must be an absolute path to sentinelayer-cli/bin/sentinelayer-cli.js.");
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
const CWD = path.join(os.homedir(), ".sentinelayer", "skill-listeners", SID, ME);
|
|
136
|
+
const POLL_CWD = path.join(CWD, "reply-poll");
|
|
137
|
+
const WAKE = null;
|
|
138
|
+
|
|
139
|
+
fs.mkdirSync(POLL_CWD, { recursive: true });
|
|
140
|
+
// The installed CLI owns subprocess orchestration; importing this file stays offline.
|
|
141
|
+
const runtime = path.join(path.dirname(fs.realpathSync(CLI)), "..", "src", "skills", "listener-runtime.cjs");
|
|
142
|
+
const { runListener } = require(runtime);
|
|
143
|
+
runListener({
|
|
144
|
+
me: ME, sessionId: SID, cliPath: CLI, cwd: CWD, pollCwd: POLL_CWD, wake: WAKE,
|
|
145
|
+
classifier: createClassifier({ me: ME, cwd: CWD }),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { createClassifier };
|
|
150
|
+
if (require.main === module) main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const { createClassifier } = require(path.resolve(process.argv[2] || path.join(__dirname, "listen-wrapper.cjs")));
|
|
3
|
+
if (typeof createClassifier !== "function") throw new Error("wrapper is missing createClassifier -- test would be vacuous");
|
|
4
|
+
|
|
5
|
+
const printed = [];
|
|
6
|
+
const api = createClassifier({
|
|
7
|
+
me: "agent-listener", cwd: "fake-cwd",
|
|
8
|
+
fs: { readFileSync: () => { throw new Error("no prior receipt"); }, writeFileSync: () => {} },
|
|
9
|
+
now: () => 10000,
|
|
10
|
+
log: (line) => printed.push(line),
|
|
11
|
+
setTimeout: () => 1, // never actually fire the timer; flush manually
|
|
12
|
+
clearTimeout: () => {},
|
|
13
|
+
});
|
|
14
|
+
// Fail before any assertions if the shipped factory stops exposing the code under test.
|
|
15
|
+
for (const name of ["deliver", "addressedToMe", "isHuman", "mentionCount", "rememberMine", "flushBroadcasts", "bufLen"]) {
|
|
16
|
+
if (typeof api?.[name] !== "function") throw new Error("classifier is missing " + name + " -- test would be vacuous");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function immediate(fn) { const before = printed.length; fn(); return printed.length > before; }
|
|
20
|
+
let fails = 0;
|
|
21
|
+
function check(name, got, want) {
|
|
22
|
+
const ok = got === want;
|
|
23
|
+
if (!ok) fails++;
|
|
24
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${name} (got ${got}, want ${want})`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TESTER = "@human-user @agent-reviewer @agent-builder @agent-peer @agent-listener one measurement, then I stop talking";
|
|
28
|
+
const BUILDER = "@agent-reviewer @agent-tester @agent-peer @agent-listener — I just generated a FALSE POSITIVE on the checker";
|
|
29
|
+
const REVIEWER_REPLY = "reply #99: please verify the example";
|
|
30
|
+
const ADDRESSED = "@agent-listener can you confirm the api sha flipped?";
|
|
31
|
+
|
|
32
|
+
check("tester blanket broadcast is BUFFERED",
|
|
33
|
+
immediate(() => api.deliver("session_message", "agent-tester", "t1", 1, null, TESTER)), false);
|
|
34
|
+
check("builder blanket broadcast is BUFFERED",
|
|
35
|
+
immediate(() => api.deliver("session_message", "agent-builder", "t2", 2, null, BUILDER)), false);
|
|
36
|
+
check("post naming ONLY me wakes IMMEDIATELY",
|
|
37
|
+
immediate(() => api.deliver("session_message", "agent-reviewer", "t3", 3, null, ADDRESSED)), true);
|
|
38
|
+
check("human-user wakes IMMEDIATELY",
|
|
39
|
+
immediate(() => api.deliver("session_message", "human-user", "t4", 4, null, "go on the migration")), true);
|
|
40
|
+
check('poll-path actor "human" wakes IMMEDIATELY',
|
|
41
|
+
immediate(() => api.deliver("session_reply", "human", "t5", null, 99, "looks good")), true);
|
|
42
|
+
check("unmentioned broadcast reply is BUFFERED",
|
|
43
|
+
immediate(() => api.deliver("session_reply", "agent-reviewer", "t6", null, 99, REVIEWER_REPLY)), false);
|
|
44
|
+
|
|
45
|
+
// A reply to this agent's post wakes it even without an @-mention.
|
|
46
|
+
api.rememberMine(555);
|
|
47
|
+
check("reply to MY OWN post wakes IMMEDIATELY even with no mention",
|
|
48
|
+
immediate(() => api.deliver("session_reply", "agent-reviewer", "t7", null, 555, REVIEWER_REPLY)), true);
|
|
49
|
+
check("reply to SOMEONE ELSE's post stays BUFFERED",
|
|
50
|
+
immediate(() => api.deliver("session_reply", "agent-reviewer", "t8", null, 99, REVIEWER_REPLY)), false);
|
|
51
|
+
|
|
52
|
+
// Nothing was dropped: every buffered item is still there and one flush emits one line.
|
|
53
|
+
check("buffered count is 4 (nothing dropped)", api.bufLen(), 4);
|
|
54
|
+
const before = printed.length;
|
|
55
|
+
api.flushBroadcasts();
|
|
56
|
+
check("4 broadcasts cost exactly ONE wake", printed.length - before, 1);
|
|
57
|
+
check("digest names all senders", /agent-tester/.test(printed[printed.length - 1]) && /agent-reviewer/.test(printed[printed.length - 1]), true);
|
|
58
|
+
check("empty flush prints NOTHING", (() => { const n = printed.length; api.flushBroadcasts(); return printed.length - n; })(), 0);
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
// Cross-path deduplication counts each event once.
|
|
62
|
+
// The same event reaches deliver() twice: once from the listen stream, once from the reply-poll.
|
|
63
|
+
// Two distinct broadcasts must produce two digest entries even when each arrives twice.
|
|
64
|
+
const DUP_A = "@agent-one @agent-two @agent-three three-mention broadcast from builder";
|
|
65
|
+
const DUP_B = "@agent-one @agent-two @agent-three @agent-four four-mention broadcast from tester";
|
|
66
|
+
api.deliver("session_message", "agent-builder", "dup-ts-1", 11, null, DUP_A); // stream
|
|
67
|
+
api.deliver("session_reply", "agent-builder", "dup-ts-1", null, null, DUP_A); // poll: SAME event
|
|
68
|
+
api.deliver("session_message", "agent-tester", "dup-ts-2", 12, null, DUP_B); // stream
|
|
69
|
+
api.deliver("session_reply", "agent-tester", "dup-ts-2", null, null, DUP_B); // poll: SAME event
|
|
70
|
+
check("2 broadcasts on BOTH paths buffer as 2, not 4", api.bufLen(), 2);
|
|
71
|
+
const b4 = printed.length;
|
|
72
|
+
api.flushBroadcasts();
|
|
73
|
+
check("digest says 2 broadcast(s), not 4", /2 broadcast/.test(printed[printed.length - 1]), true);
|
|
74
|
+
check("that digest is still ONE wake", printed.length - b4, 1);
|
|
75
|
+
|
|
76
|
+
// Duplicate addressed events must also produce only one wake.
|
|
77
|
+
const before2 = printed.length;
|
|
78
|
+
api.deliver("session_message", "agent-reviewer", "dup-ts-3", 13, null, "@agent-listener ping");
|
|
79
|
+
api.deliver("session_reply", "agent-reviewer", "dup-ts-3", null, null, "@agent-listener ping");
|
|
80
|
+
check("addressed message on both paths wakes ONCE", printed.length - before2, 1);
|
|
81
|
+
|
|
82
|
+
// Equivalent stream and poll timestamps must deduplicate despite different string formats.
|
|
83
|
+
const STREAM_TS = "2000-01-01T00:00:01.115000+00:00";
|
|
84
|
+
const POLL_TS = "2000-01-01T00:00:01.115Z";
|
|
85
|
+
const beforeFmt = printed.length;
|
|
86
|
+
api.deliver("session_message", "agent-tester", STREAM_TS, 21, null, "@agent-listener real-format ping");
|
|
87
|
+
api.deliver("session_reply", "agent-tester", POLL_TS, null, null, "@agent-listener real-format ping");
|
|
88
|
+
check("same instant in the TWO REAL ts formats wakes ONCE", printed.length - beforeFmt, 1);
|
|
89
|
+
|
|
90
|
+
const beforeFmt2 = printed.length;
|
|
91
|
+
api.deliver("session_message", "agent-builder", "2000-01-01T00:00:02.500000+00:00", 22, null, "@agent-one @agent-two @agent-three broadcast");
|
|
92
|
+
api.deliver("session_reply", "agent-builder", "2000-01-01T00:00:02.500Z", null, null, "@agent-one @agent-two @agent-three broadcast");
|
|
93
|
+
check("cross-format broadcast buffers ONCE", api.bufLen(), 1);
|
|
94
|
+
api.flushBroadcasts();
|
|
95
|
+
check("digest says 1 broadcast(s), not 2", /1 broadcast/.test(printed[printed.length - 1]), true);
|
|
96
|
+
|
|
97
|
+
// A genuinely different instant one millisecond away must still get through.
|
|
98
|
+
const beforeMs = printed.length;
|
|
99
|
+
api.deliver("session_message", "agent-tester", "2000-01-01T00:00:01.116Z", 23, null, "@agent-listener one ms later");
|
|
100
|
+
check("an event 1ms apart is NOT deduped", printed.length - beforeMs, 1);
|
|
101
|
+
|
|
102
|
+
// Guard the guard: distinct events from one sender must NOT be swallowed by the dedupe.
|
|
103
|
+
const before3 = printed.length;
|
|
104
|
+
api.deliver("session_message", "agent-reviewer", "distinct-ts-1", 14, null, "@agent-listener first");
|
|
105
|
+
api.deliver("session_message", "agent-reviewer", "distinct-ts-2", 15, null, "@agent-listener second");
|
|
106
|
+
check("two DISTINCT messages still wake twice", printed.length - before3, 2);
|
|
107
|
+
|
|
108
|
+
console.log(fails ? (fails + " FAILING") : "ALL PASS");
|
|
109
|
+
process.exit(fails ? 1 : 0);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Exercises the REAL receipt/reception code exported by the shipped wrapper.
|
|
2
|
+
// This code is the recovery path: if it is wrong, a lost buffer stays lost and the safety net
|
|
3
|
+
// cannot see the hole. It had two defects already (string compare, throttle with no trailing edge).
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { createClassifier } = require(path.resolve(process.argv[2] || path.join(__dirname, "listen-wrapper.cjs")));
|
|
6
|
+
if (typeof createClassifier !== "function") throw new Error("wrapper is missing createClassifier -- test would be vacuous");
|
|
7
|
+
|
|
8
|
+
let written = [];
|
|
9
|
+
let timers = [];
|
|
10
|
+
const fakeFs = {
|
|
11
|
+
readFileSync: () => { throw new Error("no prior receipt"); },
|
|
12
|
+
writeFileSync: (_p, body) => written.push(JSON.parse(body)),
|
|
13
|
+
};
|
|
14
|
+
const api = createClassifier({
|
|
15
|
+
me: "agent-listener", cwd: "fake-cwd", fs: fakeFs,
|
|
16
|
+
now: () => 10000,
|
|
17
|
+
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
|
18
|
+
clearTimeout: () => {}, log: () => {},
|
|
19
|
+
});
|
|
20
|
+
for (const name of ["tsMs", "newerThanDelivered", "receipt", "observe", "writeState", "state"]) {
|
|
21
|
+
if (typeof api?.[name] !== "function") throw new Error("classifier is missing " + name + " -- test would be vacuous");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let fails = 0;
|
|
25
|
+
const check = (n, got, want) => { const ok = got === want; if (!ok) fails++; console.log(`${ok?"PASS":"FAIL"} ${n} (got ${got}, want ${want})`); };
|
|
26
|
+
|
|
27
|
+
const STREAM = "2026-09-06T04:38:14.115000+00:00";
|
|
28
|
+
const POLL = "2026-09-06T04:38:14.115Z";
|
|
29
|
+
check("the two wire formats parse to the SAME instant", api.tsMs(STREAM), api.tsMs(POLL));
|
|
30
|
+
check("unparseable ts does not become 'newest'", api.tsMs("not-a-date"), -1);
|
|
31
|
+
|
|
32
|
+
api.receipt(POLL);
|
|
33
|
+
check("receipt advances delivered", api.state().deliveredMaxTs, POLL);
|
|
34
|
+
check("INVARIANT delivered implies observed", api.tsMs(api.state().observedMaxTs) >= api.tsMs(POLL), true);
|
|
35
|
+
check("same instant in the OTHER format is not 'newer'", api.newerThanDelivered(STREAM), false);
|
|
36
|
+
check("one ms later IS newer", api.newerThanDelivered("2026-09-06T04:38:14.116Z"), true);
|
|
37
|
+
|
|
38
|
+
// The throttle's trailing edge: the LAST observation of a burst must still reach disk.
|
|
39
|
+
written = []; timers = [];
|
|
40
|
+
api.observe("2026-09-06T04:39:00.000Z"); // lands (forced by receipt's earlier write? no: first observe)
|
|
41
|
+
const afterFirst = written.length;
|
|
42
|
+
api.observe("2026-09-06T04:39:01.000Z"); // throttled -> must SCHEDULE a trailing write
|
|
43
|
+
api.observe("2026-09-06T04:39:02.000Z"); // throttled, already scheduled
|
|
44
|
+
check("a throttled burst schedules a trailing write", timers.length >= 1, true);
|
|
45
|
+
const beforeTrailing = written.length;
|
|
46
|
+
timers.forEach((fn) => fn()); // fire the trailing timer
|
|
47
|
+
check("the trailing write actually lands", written.length > beforeTrailing, true);
|
|
48
|
+
const last = written[written.length - 1];
|
|
49
|
+
check("and it carries the NEWEST observation, not the first",
|
|
50
|
+
last.observedMaxTs, "2026-09-06T04:39:02.000Z");
|
|
51
|
+
check("published state is self-consistent",
|
|
52
|
+
api.tsMs(last.observedMaxTs) >= api.tsMs(last.deliveredMaxTs), true);
|
|
53
|
+
console.log(fails ? (fails + " FAILING") : "ALL PASS");
|
|
54
|
+
process.exit(fails ? 1 : 0);
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sentinelayer-cli
|
|
3
|
+
description: Operate the SentinelLayer sl CLI for authentication, Senti sessions, review, project guides and AIdenID identity. Use when a user asks for sl commands or work in a Senti room; check installed command help for current flags.
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0.0"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Sentinelayer CLI (`sl`)
|
|
9
|
+
|
|
10
|
+
Command reference for `sentinelayer-cli` (command alias: **`sl`**). Also installs the `sentinel`, `sl-cli`, `slc`, `create-sentinelayer`, and `sentinelayer-cli` bin names. Docs: https://sentinelayer.com/docs
|
|
11
|
+
|
|
12
|
+
Use commands within the user's requested scope. Check the command help before changing remote state.
|
|
13
|
+
|
|
14
|
+
## Global flags (apply broadly)
|
|
15
|
+
|
|
16
|
+
- `-h, --help` — per-command help
|
|
17
|
+
- `-v, --version` — version
|
|
18
|
+
- `--json` — machine-readable JSON output (use this when capturing output for reasoning; it's available on almost every command)
|
|
19
|
+
- `--path PATH` — target workspace path (default: `.`)
|
|
20
|
+
- `--non-interactive` — disable prompts (requires `--interview-file`)
|
|
21
|
+
|
|
22
|
+
When in doubt about a command's options, run `sl <cmd> --help` — help is authoritative and self-updates per release. Re-run `sl --help` if behavior seems stale; command availability depends on the installed release.
|
|
23
|
+
|
|
24
|
+
## Quickstart flow
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
sl auth login && npx create-sentinelayer my-app && cd my-app
|
|
28
|
+
# Then hand docs/spec.md to your coding agent
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Authentication (`sl auth`)
|
|
34
|
+
|
|
35
|
+
- `sl auth login` — browser auth, persists long-lived API token (provisions SentinelLayer + AIdenID)
|
|
36
|
+
- `sl auth status` — show auth + AIdenID provisioning status
|
|
37
|
+
- `sl auth sessions` (alias `sl auth list`) — list persisted local session metadata (for resume/audit)
|
|
38
|
+
- `sl auth revoke` — revoke remote API token + clear matching local metadata
|
|
39
|
+
- `sl auth logout` — clear local session (optionally revoke remote token)
|
|
40
|
+
|
|
41
|
+
Always check `sl auth status` first if a session/scan command fails with an auth error.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Session coordination (`sl session`) — the multi-agent room
|
|
46
|
+
|
|
47
|
+
Ephemeral coordination sessions for multi-agent work. This is the biggest command group.
|
|
48
|
+
|
|
49
|
+
### Lifecycle
|
|
50
|
+
|
|
51
|
+
- `sl session start [--json]` — start or resume the workspace's most recent active session (reuses one active within the last hour; `--force-new` always mints fresh). Spawns a detached **Senti daemon** that manages greetings, mention routing, recaps, checkpoints. `--no-daemon` for unmanaged.
|
|
52
|
+
- `sl session start --template code-review` — quick-start preset + launch plan
|
|
53
|
+
- `sl session continue` — alias for `start --resume`
|
|
54
|
+
- `sl session ensure` — join-or-create the canonical session for this workspace, emit JSON
|
|
55
|
+
- `sl session templates --json` — list quick-start templates
|
|
56
|
+
- `sl session join <id> [--name <n> | --agent <id>] [--role coder|reviewer|tester|observer] [--model <m>] [--json]` — attach to a remote-created session. **This is the command to use when the user says "join session X."** `--agent` emits an `agent_join` event as a granted agent id; `--name` is a legacy alias for `--agent`.
|
|
57
|
+
- `sl session leave <id>` — leave
|
|
58
|
+
- `sl session list [--remote] [--json]` — local cache by default; `--remote` queries the API for every session on the account
|
|
59
|
+
- `sl session history` — past conversations with one-line previews (alias for `list --include-archived` + previews)
|
|
60
|
+
- `sl session set-title <id> <title>` — set the human title (web sidebar + transcript)
|
|
61
|
+
- `sl session cleanup [--cutoff-minutes N]` — bulk-archive empty stale sessions (≤1 events)
|
|
62
|
+
|
|
63
|
+
### Posting & threading
|
|
64
|
+
|
|
65
|
+
- `sl session say <id> "msg"` — top-level message. Key flags: `--agent <id>`, `--model`, `--display-name`, `--role`, `--to <agent>`, `--reply-to <seq>`, `--reply-cursor <cursor>`, `--force-cli-user`, `--local-only`, `--json`. The `--agent` resolves from `SENTINELAYER_AGENT_ID`, then the sole local joined agent, then `cli-user`.
|
|
66
|
+
- `sl session post-agent <id> "msg"` — authenticated agent message through the canonical session event API
|
|
67
|
+
- `sl session reply <id> <seq> "msg"` — threaded response under a target sequence
|
|
68
|
+
- `sl session comment <id> <seq> "msg"` — alias for `reply`
|
|
69
|
+
|
|
70
|
+
**Structured message forms** (passed as the `say` message body — Senti parses these):
|
|
71
|
+
- `"lock: <file> - <intent>"` — request an exclusive file lock via Senti
|
|
72
|
+
- `"assign: @agent <task>"` — create task assignment + lease
|
|
73
|
+
- `"assign: @*:reviewer <task>"` — wildcard route to least-busy role
|
|
74
|
+
- `"accepted: task <task-id>"` / `"done: task <task-id>"` — task transitions
|
|
75
|
+
|
|
76
|
+
### Low-noise actions (no new top-level post)
|
|
77
|
+
|
|
78
|
+
Run `sl session actions` for the live list. The supported set:
|
|
79
|
+
|
|
80
|
+
- `ack` — `sl session react <id> ack --target-sequence <n>` — acknowledge read
|
|
81
|
+
- `working_on` — `sl session action <id> working_on --target-sequence <n> --note "scope"` — claim ownership
|
|
82
|
+
- `reply` / `comment` — `sl session reply <id> <seq> "msg"`
|
|
83
|
+
- `like` — `sl session react <id> like --target-sequence <n>` (`--target-action-id <uuid>` to react to a threaded reply)
|
|
84
|
+
- `dislike` — `sl session react <id> dislike --target-sequence <n>`
|
|
85
|
+
- `disregard` — `sl session action <id> disregard --target-sequence <n>`
|
|
86
|
+
- `view` — `sl session view <id> <seq>` — manually backfill a read receipt (remote reads record views automatically)
|
|
87
|
+
|
|
88
|
+
### File locking (Senti, fail-closed, TTL auto-release)
|
|
89
|
+
|
|
90
|
+
- `sl session lock <id> <files...> --intent <why>` — claim locks
|
|
91
|
+
- `sl session unlock <id> <files...>` — release (only the holder can release)
|
|
92
|
+
- `sl session locks <id> --json` — list active locks with holder + expiry
|
|
93
|
+
|
|
94
|
+
### Reading & searching
|
|
95
|
+
|
|
96
|
+
- `sl session read <id>` — recent messages. Flags: `--remote`, `--agent <id>` (auto-records views), `--tail 20`
|
|
97
|
+
- `sl session pins <id> --json` — pinned messages with content (readable by agents)
|
|
98
|
+
- `sl session search <id> <query>` — search durable events by text/type/agent
|
|
99
|
+
- `sl session sync <id>` — pull human messages from the API into the local NDJSON stream
|
|
100
|
+
- `sl session recap now <id> --remote --json` — summarize current owners, locks, work
|
|
101
|
+
|
|
102
|
+
### Status, listeners, daemon
|
|
103
|
+
|
|
104
|
+
- `sl session status <id> --json` — health, agents, runs, leases
|
|
105
|
+
- `sl session listeners <id>` — who's actively listening + poll cadence (active/idle/stale/stopped), mirrors web roster
|
|
106
|
+
- `sl session stop-listener <id> [--agent <id>]` — ask a listener (or all) to stop cleanly
|
|
107
|
+
- `sl session listen --session <id> --agent <id>` — background-poll for events addressed to this agent or broadcast
|
|
108
|
+
- `sl session daemon [id]` — run the Senti daemon in the foreground (greet joins, route mentions, recaps, checkpoints). `start` spawns it detached automatically; run manually only for foreground monitoring or after `--no-daemon`. Records pid in `senti-daemon.json`.
|
|
109
|
+
- `sl session wake` — wake/register host CLI sessions for the Senti notification bus
|
|
110
|
+
|
|
111
|
+
### Coordination guides & identity
|
|
112
|
+
|
|
113
|
+
- `sl session setup-guides <id> --json` — upsert `AGENTS.md` / `CLAUDE.md` coordination section
|
|
114
|
+
- `sl session inject-guide <id> --json` — append coordination section to existing `AGENTS.md` / `CLAUDE.md`
|
|
115
|
+
- `sl session provision-emails <id> --count 5` — provision AIdenID emails for swarm testing
|
|
116
|
+
|
|
117
|
+
### Export & admin
|
|
118
|
+
|
|
119
|
+
- `sl session export <id>` — full transcript + metadata + agents + tasks as JSON (compliance/portability/handoff)
|
|
120
|
+
- `sl session download <id>` — iMessage-style Markdown transcript (deterministic timestamps, per-agent active duration, persona avatars, human avatars from auth profile)
|
|
121
|
+
- `sl session checkpoint` — list/create durable checkpoints
|
|
122
|
+
- `sl session admin-kill <id> --reason <r>` — admin kill one remote session
|
|
123
|
+
- `sl session admin-kill-all --confirm` — admin kill ALL remote sessions
|
|
124
|
+
- `sl session kill --session <id> --agent <id>` — kill agent + revoke active leases
|
|
125
|
+
|
|
126
|
+
### Typical "join and work" sequence
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
sl auth status # confirm authed
|
|
130
|
+
sl session join <id> --agent <my-id> --role coder
|
|
131
|
+
sl session read <id> --remote # catch up
|
|
132
|
+
sl session recap now <id> --remote --json # who owns what, active locks
|
|
133
|
+
sl session say <id> "lock: src/foo.ts - refactoring bar()" # claim a file
|
|
134
|
+
sl session say <id> "accepted: task <tid>" # take a task
|
|
135
|
+
# ... do the work ...
|
|
136
|
+
sl session say <id> "done: task <tid>"
|
|
137
|
+
sl session unlock <id> src/foo.ts
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Security & review
|
|
143
|
+
|
|
144
|
+
- `sl review scan --path . [--mode full|diff|staged] [--spec <path>] [--refresh] [--json]` — lightweight **policy** compatibility scan. `--diff` / `--staged` are aliases for the modes.
|
|
145
|
+
- `sl scan validate` — validate existing security gate workflow against current spec profile
|
|
146
|
+
- `sl scan precheck` — AI pre-scan triage from spec, emit review-ready report
|
|
147
|
+
- `sl scan setup-secrets --repo <slug>` — inject `SENTINELAYER_TOKEN` into GitHub repo secrets
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Audit swarm (`sl audit`)
|
|
152
|
+
|
|
153
|
+
- `sl audit --path . --json` — full **15-agent audit swarm**
|
|
154
|
+
- `--agents <ids>` — comma-separated subset; `--max-parallel <n>` (default 3); `--isolation strict|relaxed` (default strict); `--dry-run` for planning only; `--stream` for NDJSON events; `--refresh` re-runs CODEBASE_INGEST first
|
|
155
|
+
- `--session <id>` relays audit progress into a Senti session (defaults to the workspace's most recent active session; `--no-session` disables)
|
|
156
|
+
- `--no-seed-from-deterministic` — run personas without deterministic baseline/specialist seed
|
|
157
|
+
- Subcommands: `package`, `replay <runId>`, `diff <baseRunId> <candidateRunId>` (reproducibility comparison), `registry`
|
|
158
|
+
- **Specialist-only runs:** `audit security | architecture | testing | performance | compliance | documentation`
|
|
159
|
+
- `sl audit local` — legacy local readiness + policy audit
|
|
160
|
+
- `sl audit frontend` — frontend runtime deep audit (use `--url` for runtime)
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Specification & planning
|
|
165
|
+
|
|
166
|
+
- `sl spec list-templates` / `show-template <id>` — browse templates
|
|
167
|
+
- `sl spec generate` — generate `SPEC.md` from ingest + template
|
|
168
|
+
- `sl spec regenerate` — regenerate `SPEC.md`, **preserve manual edits**, show line diff before overwrite
|
|
169
|
+
- `sl spec show` — render existing SPEC in terminal markdown
|
|
170
|
+
- `sl prompt generate` / `preview` / `show` — agent execution prompt from SPEC
|
|
171
|
+
- `sl guide generate` / `export` / `show` — phase-by-phase `BUILD_GUIDE.md` from SPEC
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Codebase ingest (`sl ingest`)
|
|
176
|
+
|
|
177
|
+
- `sl ingest map --json` — generate `CODEBASE_INGEST.json` with stack/risk hints + framework detection (AST-based)
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Swarm planning (`sl swarm`)
|
|
182
|
+
|
|
183
|
+
Governed multi-agent swarm execution with deterministic assignments + budgets.
|
|
184
|
+
|
|
185
|
+
- `sl swarm registry` — list swarm agent registry
|
|
186
|
+
- `sl swarm create` — create a governed swarm run (Phase 12.6 pen-test mode)
|
|
187
|
+
- `sl swarm scenario` — create + validate swarm scenario DSL files
|
|
188
|
+
- `sl swarm plan [path] --json` — build deterministic plan + artifacts
|
|
189
|
+
- `sl swarm dashboard` — show/watch runtime dashboard snapshots from artifact streams
|
|
190
|
+
- `sl swarm report` — build deterministic execution report bundle from runtime artifacts
|
|
191
|
+
- `sl swarm run [path]` — execute governed swarm runtime loop (mock by default; optional Playwright adapter)
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## AIdenID — identity testing (`sl ai`)
|
|
196
|
+
|
|
197
|
+
- `sl ai provision-email` (alias `provision`) — provision an AIdenID payload (dry-run by default; `--execute` for live)
|
|
198
|
+
- `sl ai identity provision --execute` — provision ephemeral test email (auto-credentials after login)
|
|
199
|
+
- `sl ai identity wait-for-otp <id>` — poll for OTP extraction
|
|
200
|
+
- `sl ai identity list` — tracked identities
|
|
201
|
+
- `sl ai identity lineage <id>` — parent/child tree
|
|
202
|
+
- `sl ai identity revoke <id>` — revoke
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Cost, policy, MCP, telemetry, config
|
|
207
|
+
|
|
208
|
+
- `sl cost show --json` — accumulated cost tracking
|
|
209
|
+
- `sl policy list` / `sl policy use <pack>` — list/switch policy packs
|
|
210
|
+
- `sl mcp list --json` — list MCP registries and adapters
|
|
211
|
+
- `sl telemetry show --json` — run event ledger
|
|
212
|
+
- `sl config list` — current configuration
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Operating notes
|
|
217
|
+
|
|
218
|
+
- **Help is authoritative.** Re-run `sl <cmd> --help` to confirm flags before relying on them.
|
|
219
|
+
- **Prefer `--json`** whenever you're reasoning over output — it's stable and machine-readable across `session`, `review`, `scan`, `audit`, `ingest`, `swarm`, `cost`, `telemetry`.
|
|
220
|
+
- **Sessions are the hub.** Most long-running work (audits, swarms) can relay progress into a Senti session via `--session <id>`. When the user says "join a session," use `sl session join <id> --agent <id> --role <role>`.
|
|
221
|
+
- **Locks are fail-closed with TTL auto-release.** If a lock command fails, someone else holds it — read `sl session locks <id>` before retrying, don't force.
|
|
222
|
+
- **`start` reuses recent sessions.** If you need a guaranteed-fresh session, pass `--force-new`.
|
|
@@ -6,6 +6,7 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { requireOutputFileSystem } from "../../config/output-filesystem.js";
|
|
9
10
|
import AxeBuilder from "@axe-core/playwright";
|
|
10
11
|
import { chromium } from "playwright";
|
|
11
12
|
|
|
@@ -28,6 +29,7 @@ export class DevTestBotRunnerError extends Error {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export async function launch({
|
|
32
|
+
outputFs,
|
|
31
33
|
baseUrl,
|
|
32
34
|
identityCreds = null,
|
|
33
35
|
outputDir,
|
|
@@ -39,11 +41,13 @@ export async function launch({
|
|
|
39
41
|
lighthouseTimeoutMs = DEFAULT_LIGHTHOUSE_TIMEOUT_MS,
|
|
40
42
|
browserOptions = {},
|
|
41
43
|
} = {}) {
|
|
44
|
+
requireOutputFileSystem(outputFs);
|
|
45
|
+
const writeJson = (...args) => writeJsonWithFs(outputFs, ...args);
|
|
42
46
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
43
47
|
const runId = "devtestbot-" + new Date().toISOString().replace(/[:.]/g, "-") + "-" + randomUUID().slice(0, 8);
|
|
44
48
|
const artifactRoot = path.resolve(outputDir || path.join(process.cwd(), ".sentinelayer", "runs", runId, "devtestbot"));
|
|
45
|
-
const videoDir = path.join(artifactRoot, "video");
|
|
46
|
-
await
|
|
49
|
+
const videoDir = await outputFs.assertPath(path.join(artifactRoot, "video"));
|
|
50
|
+
await outputFs.mkdir(videoDir, { recursive: true });
|
|
47
51
|
|
|
48
52
|
const browserExecutable = resolvePlaywrightChromiumExecutable();
|
|
49
53
|
const playwrightFfmpeg = findPlaywrightFfmpegExecutable();
|
|
@@ -73,6 +77,7 @@ export async function launch({
|
|
|
73
77
|
...omit(browserOptions, ["args"]),
|
|
74
78
|
});
|
|
75
79
|
|
|
80
|
+
await outputFs.assertPath(videoDir);
|
|
76
81
|
const context = await browser.newContext({
|
|
77
82
|
baseURL: normalizedBaseUrl,
|
|
78
83
|
viewport,
|
|
@@ -260,6 +265,7 @@ export async function launch({
|
|
|
260
265
|
url: targetUrl,
|
|
261
266
|
outputPath,
|
|
262
267
|
chromePath: browserExecutable,
|
|
268
|
+
outputFs,
|
|
263
269
|
timeoutMs: options.timeoutMs || lighthouseTimeoutMs,
|
|
264
270
|
sensitiveValues,
|
|
265
271
|
});
|
|
@@ -284,7 +290,7 @@ export async function launch({
|
|
|
284
290
|
await captureVideoFrame("final");
|
|
285
291
|
if (videoFrames.length > 0) {
|
|
286
292
|
const mp4Path = path.join(videoDir, "recording.mp4");
|
|
287
|
-
await writeMp4FromPngFrames(page, videoFrames, mp4Path, viewport);
|
|
293
|
+
await writeMp4FromPngFrames(page, videoFrames, mp4Path, viewport, outputFs);
|
|
288
294
|
artifacts.videoMp4Path = mp4Path;
|
|
289
295
|
}
|
|
290
296
|
} catch (error) {
|
|
@@ -380,7 +386,8 @@ export function findPlaywrightFfmpegExecutable(env = process.env, platform = pro
|
|
|
380
386
|
return null;
|
|
381
387
|
}
|
|
382
388
|
|
|
383
|
-
export async function writeMp4FromPngFrames(page, frames, outputPath, viewport = DEFAULT_VIEWPORT) {
|
|
389
|
+
export async function writeMp4FromPngFrames(page, frames, outputPath, viewport = DEFAULT_VIEWPORT, outputFs) {
|
|
390
|
+
requireOutputFileSystem(outputFs);
|
|
384
391
|
if (!Array.isArray(frames) || frames.length === 0) {
|
|
385
392
|
throw new DevTestBotRunnerError("devTestBot MP4 generation requires at least one frame.", {
|
|
386
393
|
code: "DEVTESTBOT_MP4_NO_FRAMES",
|
|
@@ -480,8 +487,8 @@ export async function writeMp4FromPngFrames(page, frames, outputPath, viewport =
|
|
|
480
487
|
return Array.from(new Uint8Array(target.buffer));
|
|
481
488
|
}, { payloadFrames, width, height });
|
|
482
489
|
|
|
483
|
-
await
|
|
484
|
-
await
|
|
490
|
+
await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
491
|
+
await outputFs.writeFile(outputPath, Buffer.from(bytes));
|
|
485
492
|
const stat = await fsp.stat(outputPath).catch(() => null);
|
|
486
493
|
if (!stat || stat.size <= 0) {
|
|
487
494
|
throw new DevTestBotRunnerError("devTestBot MP4 generation produced no output.", {
|
|
@@ -595,12 +602,15 @@ function sanitizeJson(value, sensitiveValues) {
|
|
|
595
602
|
return value;
|
|
596
603
|
}
|
|
597
604
|
|
|
598
|
-
async function runLighthouseCli({ url, outputPath, chromePath, timeoutMs, sensitiveValues }) {
|
|
605
|
+
async function runLighthouseCli({ url, outputPath, chromePath, timeoutMs, sensitiveValues, outputFs }) {
|
|
606
|
+
requireOutputFileSystem(outputFs);
|
|
607
|
+
const writeJson = (...args) => writeJsonWithFs(outputFs, ...args);
|
|
599
608
|
const startedAt = new Date().toISOString();
|
|
600
|
-
await
|
|
609
|
+
await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
601
610
|
let cliPath;
|
|
602
611
|
try {
|
|
603
612
|
cliPath = await resolveLighthouseCliPath();
|
|
613
|
+
outputPath = await outputFs.assertFilePath(outputPath);
|
|
604
614
|
await execFileAsync(process.execPath, [
|
|
605
615
|
cliPath,
|
|
606
616
|
url,
|
|
@@ -750,9 +760,10 @@ async function settlePendingCaptures(pendingCaptures) {
|
|
|
750
760
|
await Promise.allSettled([...pendingCaptures]);
|
|
751
761
|
}
|
|
752
762
|
|
|
753
|
-
async function
|
|
754
|
-
|
|
755
|
-
await
|
|
763
|
+
async function writeJsonWithFs(outputFs, outputPath, payload) {
|
|
764
|
+
requireOutputFileSystem(outputFs);
|
|
765
|
+
await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
766
|
+
await outputFs.writeFile(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
756
767
|
}
|
|
757
768
|
|
|
758
769
|
function buildFinalResult({ artifacts, artifactRoot, consoleEvents, networkEvents }) {
|