@ouro.bot/cli 0.1.0-alpha.893 → 0.1.0-alpha.894
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/changelog.json +6 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/steward-policy.js +26 -0
- package/dist/repertoire/guardrails.js +1 -1
- package/dist/repertoire/tools-cmux.js +121 -2
- package/dist/senses/cmux/answer.js +191 -0
- package/dist/senses/cmux/attention.js +4 -2
- package/dist/senses/cmux/casebook.js +416 -0
- package/dist/senses/cmux/floor.js +407 -0
- package/dist/senses/cmux/principles.js +72 -0
- package/dist/senses/cmux/sense.js +52 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.REPLY_HISTORY_MS = void 0;
|
|
37
|
+
exports.cmuxCasebookPath = cmuxCasebookPath;
|
|
38
|
+
exports.cmuxDecisionLogPath = cmuxDecisionLogPath;
|
|
39
|
+
exports.storeShape = storeShape;
|
|
40
|
+
exports.isShape = isShape;
|
|
41
|
+
exports.loadCasebook = loadCasebook;
|
|
42
|
+
exports.readCasebook = readCasebook;
|
|
43
|
+
exports.findPrecedent = findPrecedent;
|
|
44
|
+
exports.addCase = addCase;
|
|
45
|
+
exports.withDecisionLock = withDecisionLock;
|
|
46
|
+
exports.appendDecision = appendDecision;
|
|
47
|
+
exports.readDecisionLog = readDecisionLog;
|
|
48
|
+
exports.readDecisions = readDecisions;
|
|
49
|
+
exports.repliesInWindow = repliesInWindow;
|
|
50
|
+
const fs = __importStar(require("node:fs"));
|
|
51
|
+
const path = __importStar(require("node:path"));
|
|
52
|
+
const node_crypto_1 = require("node:crypto");
|
|
53
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
54
|
+
const redact_1 = require("./redact");
|
|
55
|
+
const MAX_CASES = 2_000;
|
|
56
|
+
const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
57
|
+
const LOCK_WAIT_MS = 5_000;
|
|
58
|
+
const STALE_LOCK_MS = 30_000;
|
|
59
|
+
const TOMBSTONE_KEEP_MS = 5 * 60_000;
|
|
60
|
+
const heldLocks = new Set();
|
|
61
|
+
/** How long reply records survive log rotation; the standing grant's window may not be longer. */
|
|
62
|
+
exports.REPLY_HISTORY_MS = 31 * 24 * 60 * 60_000;
|
|
63
|
+
const LOST_TAKEOVER = new Set(["ENOENT", "ENOTEMPTY", "EEXIST"]);
|
|
64
|
+
function cmuxCasebookPath(stateDir) {
|
|
65
|
+
return path.join(stateDir, "casebook.json");
|
|
66
|
+
}
|
|
67
|
+
function cmuxDecisionLogPath(stateDir) {
|
|
68
|
+
return path.join(stateDir, "decisions.jsonl");
|
|
69
|
+
}
|
|
70
|
+
function ensurePrivateDir(dir) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
fs.chmodSync(dir, 0o700);
|
|
73
|
+
}
|
|
74
|
+
function storeShape(shape) {
|
|
75
|
+
return {
|
|
76
|
+
repoRoot: shape.repoRoot,
|
|
77
|
+
tool: shape.tool,
|
|
78
|
+
digest: (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(shape.tokens)).digest("hex"),
|
|
79
|
+
preview: (0, redact_1.redactSecrets)(shape.tokens.join(" ")).slice(0, 300),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function isShape(value) {
|
|
83
|
+
const shape = value;
|
|
84
|
+
return !!shape && typeof shape === "object" && typeof shape.repoRoot === "string" && typeof shape.tool === "string"
|
|
85
|
+
&& typeof shape.digest === "string" && typeof shape.preview === "string";
|
|
86
|
+
}
|
|
87
|
+
/** A schema-1 shape kept the raw command tokens; it migrates to a digest and a redacted preview. */
|
|
88
|
+
function isLegacyShape(value) {
|
|
89
|
+
const shape = value;
|
|
90
|
+
return !!shape && typeof shape === "object" && typeof shape.repoRoot === "string" && typeof shape.tool === "string"
|
|
91
|
+
&& Array.isArray(shape.tokens) && shape.tokens.every((token) => typeof token === "string");
|
|
92
|
+
}
|
|
93
|
+
function isVerdict(value) {
|
|
94
|
+
return value === "once" || value === "ask";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Reads the casebook. Schema-1 cases are converted to the stored shape (digest and redacted
|
|
98
|
+
* preview). A casebook that exists but cannot be read, or holds a case that is neither shape, is
|
|
99
|
+
* reported as a problem instead of being skipped, because a skipped "ask" precedent would let a
|
|
100
|
+
* request through that the human asked to be asked about.
|
|
101
|
+
*/
|
|
102
|
+
function loadCasebook(file) {
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = fs.readFileSync(file, "utf-8");
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code === "ENOENT")
|
|
109
|
+
return { cases: [], problem: null, migrated: 0 };
|
|
110
|
+
return { cases: [], problem: `the cmux casebook cannot be read: ${error.message}`, migrated: 0 };
|
|
111
|
+
}
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { cases: [], problem: "the cmux casebook is not valid JSON", migrated: 0 };
|
|
118
|
+
}
|
|
119
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.cases))
|
|
120
|
+
return { cases: [], problem: "the cmux casebook has no case list", migrated: 0 };
|
|
121
|
+
const cases = [];
|
|
122
|
+
let migrated = 0;
|
|
123
|
+
for (const value of parsed.cases) {
|
|
124
|
+
const entry = value;
|
|
125
|
+
if (!entry || typeof entry !== "object" || !isVerdict(entry.verdict))
|
|
126
|
+
return { cases: [], problem: "the cmux casebook holds a case it cannot read", migrated: 0 };
|
|
127
|
+
if (isShape(entry.shape)) {
|
|
128
|
+
cases.push(entry);
|
|
129
|
+
}
|
|
130
|
+
else if (isLegacyShape(entry.shape)) {
|
|
131
|
+
cases.push({ ...entry, shape: storeShape(entry.shape) });
|
|
132
|
+
migrated += 1;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
return { cases: [], problem: "the cmux casebook holds a case it cannot read", migrated: 0 };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { cases, problem: null, migrated };
|
|
139
|
+
}
|
|
140
|
+
function writeCasebook(file, cases) {
|
|
141
|
+
ensurePrivateDir(path.dirname(file));
|
|
142
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
143
|
+
fs.writeFileSync(temp, `${JSON.stringify({ schemaVersion: 2, cases }, null, 2)}\n`, { mode: 0o600 });
|
|
144
|
+
fs.renameSync(temp, file);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The casebook's cases, with schema-1 cases rewritten on disk as digests so raw command words
|
|
148
|
+
* leave the file. Throws while the casebook has a problem, so callers fail closed.
|
|
149
|
+
*/
|
|
150
|
+
function readCasebook(file) {
|
|
151
|
+
const first = loadCasebook(file);
|
|
152
|
+
if (first.problem)
|
|
153
|
+
throw new Error(first.problem);
|
|
154
|
+
if (first.migrated === 0)
|
|
155
|
+
return first.cases;
|
|
156
|
+
return withDecisionLock(path.dirname(file), () => {
|
|
157
|
+
const current = loadCasebook(file);
|
|
158
|
+
if (current.problem)
|
|
159
|
+
throw new Error(current.problem);
|
|
160
|
+
if (current.migrated > 0) {
|
|
161
|
+
writeCasebook(file, current.cases);
|
|
162
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.cmux_casebook_migrated", message: "migrated schema-1 cmux precedents to digests", meta: { migrated: current.migrated } });
|
|
163
|
+
}
|
|
164
|
+
return current.cases;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** The newest precedent for exactly this shape; a later "ask" overrides an earlier "once" and the reverse. */
|
|
168
|
+
function findPrecedent(cases, shape) {
|
|
169
|
+
const want = storeShape(shape);
|
|
170
|
+
for (let index = cases.length - 1; index >= 0; index -= 1) {
|
|
171
|
+
const have = cases[index].shape;
|
|
172
|
+
if (have.repoRoot === want.repoRoot && have.tool === want.tool && have.digest === want.digest)
|
|
173
|
+
return cases[index];
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
function addCase(file, input) {
|
|
178
|
+
const entry = { id: `case-${(0, node_crypto_1.randomUUID)()}`, at: input.at, verdict: input.verdict, shape: input.shape, requestId: input.requestId, note: (0, redact_1.redactSecrets)(input.note).slice(0, 500) };
|
|
179
|
+
withDecisionLock(path.dirname(file), () => {
|
|
180
|
+
const current = loadCasebook(file);
|
|
181
|
+
if (current.problem)
|
|
182
|
+
throw new Error(current.problem);
|
|
183
|
+
writeCasebook(file, [...current.cases, entry].slice(-MAX_CASES));
|
|
184
|
+
});
|
|
185
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.cmux_case_added", message: "recorded a cmux precedent", meta: { verdict: entry.verdict, tool: entry.shape.tool } });
|
|
186
|
+
return entry;
|
|
187
|
+
}
|
|
188
|
+
function errorCode(error) {
|
|
189
|
+
return error.code;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Takes over a stale lock. Each contender renames the stale directory to its own tombstone, so two
|
|
193
|
+
* contenders never collide on one name, and then checks that the directory it moved is the stale one
|
|
194
|
+
* it judged. If a fresh lock replaced the stale one in between, the contender moved someone else's
|
|
195
|
+
* live lock: it puts that lock back and tries again from the start.
|
|
196
|
+
*/
|
|
197
|
+
function takeOverStaleLock(lock, staleIno) {
|
|
198
|
+
const tombstone = `${lock}.stale-${staleIno}-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
|
|
199
|
+
try {
|
|
200
|
+
fs.renameSync(lock, tombstone);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
// Another contender moved it first; the lock is not ours to take.
|
|
204
|
+
if (errorCode(error) === "ENOENT")
|
|
205
|
+
return;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (fs.statSync(tombstone).ino !== staleIno) {
|
|
209
|
+
try {
|
|
210
|
+
fs.renameSync(tombstone, lock);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (!LOST_TAKEOVER.has(errorCode(error)))
|
|
214
|
+
throw error;
|
|
215
|
+
// A third contender locked in the meantime and its lock is not empty; leave the moved lock as a tombstone.
|
|
216
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.cmux_lock_restore_failed", message: "could not put back a cmux decision log lock moved during a takeover", meta: { tombstone } });
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
fs.writeFileSync(path.join(tombstone, "taken-over"), `${process.pid}\n`);
|
|
221
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.cmux_stale_lock_taken_over", message: "took over a stale cmux decision log lock", meta: { tombstone } });
|
|
222
|
+
}
|
|
223
|
+
/** Tombstones older than a few minutes can no longer be raced; removing them keeps the folder tidy. */
|
|
224
|
+
function sweepTombstones(stateDir, now) {
|
|
225
|
+
for (const name of fs.readdirSync(stateDir)) {
|
|
226
|
+
if (!name.startsWith("decisions.lock.stale-"))
|
|
227
|
+
continue;
|
|
228
|
+
const tombstone = path.join(stateDir, name);
|
|
229
|
+
try {
|
|
230
|
+
if (now - fs.statSync(tombstone).mtimeMs <= TOMBSTONE_KEEP_MS)
|
|
231
|
+
continue;
|
|
232
|
+
// A tombstone holds only its marker file, so it is emptied file by file, never removed recursively.
|
|
233
|
+
for (const entry of fs.readdirSync(tombstone))
|
|
234
|
+
fs.unlinkSync(path.join(tombstone, entry));
|
|
235
|
+
fs.rmdirSync(tombstone);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Another process swept it first.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Runs `fn` while holding the decision log's lock (an atomic `mkdir`), so the sense process and the
|
|
244
|
+
* agent's tool never both pass the grant's count cap. A lock older than 30 seconds is taken over
|
|
245
|
+
* (see `takeOverStaleLock`); a lock that disappears while being checked is simply tried again.
|
|
246
|
+
*/
|
|
247
|
+
function withDecisionLock(stateDir, fn, waitMs = LOCK_WAIT_MS) {
|
|
248
|
+
ensurePrivateDir(stateDir);
|
|
249
|
+
const lock = path.join(stateDir, "decisions.lock");
|
|
250
|
+
// `fn` is synchronous, so a nested call from inside it (an append while reserving a reply) already holds the lock.
|
|
251
|
+
if (heldLocks.has(lock))
|
|
252
|
+
return fn();
|
|
253
|
+
const deadline = Date.now() + waitMs;
|
|
254
|
+
let held;
|
|
255
|
+
for (;;) {
|
|
256
|
+
try {
|
|
257
|
+
fs.mkdirSync(lock);
|
|
258
|
+
held = fs.statSync(lock).ino;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (errorCode(error) !== "EEXIST")
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
let current = null;
|
|
266
|
+
try {
|
|
267
|
+
current = fs.statSync(lock);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (errorCode(error) !== "ENOENT")
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
if (current === null)
|
|
274
|
+
continue;
|
|
275
|
+
if (Date.now() - current.mtimeMs > STALE_LOCK_MS) {
|
|
276
|
+
takeOverStaleLock(lock, current.ino);
|
|
277
|
+
if (Date.now() < deadline)
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (Date.now() >= deadline)
|
|
281
|
+
throw new Error("the cmux decision log is locked");
|
|
282
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
283
|
+
}
|
|
284
|
+
heldLocks.add(lock);
|
|
285
|
+
try {
|
|
286
|
+
return fn();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
heldLocks.delete(lock);
|
|
290
|
+
// Release only the lock this call made; if it was taken over meanwhile, the new holder keeps it.
|
|
291
|
+
try {
|
|
292
|
+
if (fs.statSync(lock).ino === held)
|
|
293
|
+
fs.rmdirSync(lock);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (errorCode(error) !== "ENOENT")
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
sweepTombstones(stateDir, Date.now());
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Rotates a full log under the lock. The active file becomes `.1`; reply records from the old `.1`
|
|
304
|
+
* that are younger than `REPLY_HISTORY_MS` start the new active file, so the grant's count never
|
|
305
|
+
* loses a reply that is still inside its window.
|
|
306
|
+
*/
|
|
307
|
+
function rotateIfFull(file, now) {
|
|
308
|
+
let size;
|
|
309
|
+
try {
|
|
310
|
+
size = fs.statSync(file).size;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (errorCode(error) === "ENOENT")
|
|
314
|
+
return;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
if (size <= MAX_LOG_BYTES)
|
|
318
|
+
return;
|
|
319
|
+
const keep = parseDecisions(readText(`${file}.1`)).filter((record) => record.outcome === "reply_sent" && Date.parse(record.at) > now - exports.REPLY_HISTORY_MS);
|
|
320
|
+
fs.renameSync(file, `${file}.1`);
|
|
321
|
+
if (keep.length > 0)
|
|
322
|
+
fs.writeFileSync(file, keep.map((record) => `${JSON.stringify(record)}\n`).join(""), { mode: 0o600 });
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Appends one record under the decision log's lock. Free-text fields are redacted before the line is
|
|
326
|
+
* built, so every line stays valid JSON.
|
|
327
|
+
*/
|
|
328
|
+
function appendDecision(file, record) {
|
|
329
|
+
const safe = {
|
|
330
|
+
...record,
|
|
331
|
+
cwd: record.cwd === null ? null : (0, redact_1.redactSecrets)(record.cwd),
|
|
332
|
+
floor: { verdict: record.floor.verdict, reason: (0, redact_1.redactSecrets)(record.floor.reason) },
|
|
333
|
+
authority: (0, redact_1.redactSecrets)(record.authority),
|
|
334
|
+
detail: (0, redact_1.redactSecrets)(record.detail).slice(0, 2_000),
|
|
335
|
+
};
|
|
336
|
+
withDecisionLock(path.dirname(file), () => {
|
|
337
|
+
rotateIfFull(file, Date.parse(record.at));
|
|
338
|
+
// A crash mid-append can leave a torn last line; end it first so it cannot swallow this record.
|
|
339
|
+
if (!endsWithNewline(file))
|
|
340
|
+
fs.appendFileSync(file, "\n", { mode: 0o600 });
|
|
341
|
+
fs.appendFileSync(file, `${JSON.stringify(safe)}\n`, { mode: 0o600 });
|
|
342
|
+
fs.chmodSync(file, 0o600);
|
|
343
|
+
});
|
|
344
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.cmux_decision_logged", message: "logged a cmux prompt decision", meta: { outcome: record.outcome, tool: record.tool, floor: record.floor.verdict } });
|
|
345
|
+
}
|
|
346
|
+
/** True when the file is missing, empty or ends with a newline. */
|
|
347
|
+
function endsWithNewline(file) {
|
|
348
|
+
let fd;
|
|
349
|
+
try {
|
|
350
|
+
fd = fs.openSync(file, "r");
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
if (errorCode(error) === "ENOENT")
|
|
354
|
+
return true;
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const size = fs.fstatSync(fd).size;
|
|
359
|
+
if (size === 0)
|
|
360
|
+
return true;
|
|
361
|
+
const last = Buffer.alloc(1);
|
|
362
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
363
|
+
return last[0] === 0x0a;
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
fs.closeSync(fd);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** A missing file reads as empty. Any other error throws, so a log that cannot be read never counts as having no replies. */
|
|
370
|
+
function readText(file) {
|
|
371
|
+
try {
|
|
372
|
+
return fs.readFileSync(file, "utf-8");
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (errorCode(error) === "ENOENT")
|
|
376
|
+
return "";
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function parseDecisionLog(text) {
|
|
381
|
+
const log = { records: [], unreadable: 0 };
|
|
382
|
+
for (const line of text.split("\n")) {
|
|
383
|
+
if (line.trim() === "")
|
|
384
|
+
continue;
|
|
385
|
+
try {
|
|
386
|
+
const value = JSON.parse(line);
|
|
387
|
+
if (value && typeof value === "object" && typeof value.requestId === "string") {
|
|
388
|
+
log.records.push(value);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// Counted below.
|
|
394
|
+
}
|
|
395
|
+
log.unreadable += 1;
|
|
396
|
+
}
|
|
397
|
+
return log;
|
|
398
|
+
}
|
|
399
|
+
function parseDecisions(text) {
|
|
400
|
+
return parseDecisionLog(text).records;
|
|
401
|
+
}
|
|
402
|
+
/** Every logged decision, including the one rotated-out file, with a count of lines that could not be parsed. */
|
|
403
|
+
function readDecisionLog(file) {
|
|
404
|
+
return parseDecisionLog(`${readText(`${file}.1`)}\n${readText(file)}`);
|
|
405
|
+
}
|
|
406
|
+
/** Every logged decision, oldest first, including the one rotated-out file. */
|
|
407
|
+
function readDecisions(file) {
|
|
408
|
+
return readDecisionLog(file).records;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* How many replies the sense sent inside the window. Every attempt counts, confirmed or not, and so
|
|
412
|
+
* does every line that cannot be parsed, because its time and outcome are unknown: the cap fails closed.
|
|
413
|
+
*/
|
|
414
|
+
function repliesInWindow(log, windowMs, now) {
|
|
415
|
+
return log.records.filter((record) => record.outcome === "reply_sent" && Date.parse(record.at) > now - windowMs).length + log.unreadable;
|
|
416
|
+
}
|