akemon 0.3.4 → 0.3.5
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 +18 -0
- package/dist/cli.js +89 -40
- package/dist/engine-peripheral.js +5 -4
- package/dist/engine-routing.js +99 -0
- package/dist/event-bus.js +63 -17
- package/dist/privacy-filter.js +269 -0
- package/dist/redaction.js +159 -0
- package/dist/relay-client.js +39 -2
- package/dist/server.js +86 -101
- package/dist/software-agent-memory.js +9 -11
- package/dist/software-agent-peripheral.js +313 -20
- package/dist/software-agent-stream-cli.js +101 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,6 +175,24 @@ Current Batch 5 status: the Codex integration uses `codex exec` as a one-shot ba
|
|
|
175
175
|
|
|
176
176
|
Software-agent tasks default to the `akemon serve` workdir boundary. Use `--allow-outside-workdir` only when you explicitly want the software agent to run outside that root. Each run is recorded under `.akemon/agents/<name>/software-agent/tasks/` with the envelope, result, output summaries, and git worktree status.
|
|
177
177
|
|
|
178
|
+
The Codex child process currently inherits the `akemon serve` environment so model credentials and CLI configuration work as expected. Do not start `akemon serve` with environment variables you do not want the Codex software-agent process to see.
|
|
179
|
+
|
|
180
|
+
Common secret-like values are redacted from software-agent streams, task ledger records, relay task stream events, and the persistent event log before they are displayed or stored.
|
|
181
|
+
|
|
182
|
+
For PII-oriented filtering, Akemon also has an optional adapter for [OpenAI Privacy Filter](https://github.com/openai/privacy-filter). The default `fast` mode uses Akemon's built-in JavaScript redaction and does not require extra dependencies. To use OPF, install the external `opf` Python CLI yourself, then opt in explicitly:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
akemon privacy-filter --mode fast "OPENAI_API_KEY=sk-..."
|
|
186
|
+
akemon privacy-filter --mode pii --backend opf --device cpu "Alice was born on 1990-01-02."
|
|
187
|
+
akemon privacy-filter --mode strict --backend opf --checkpoint ~/.opf/privacy_filter "Alice ..."
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
You can also configure OPF with `AKEMON_PRIVACY_FILTER=opf`, `AKEMON_OPF_COMMAND`, `AKEMON_OPF_DEVICE`, `AKEMON_OPF_CHECKPOINT`, `AKEMON_OPF_TIMEOUT_MS`, and `AKEMON_OPF_MAX_INPUT_CHARS`. In `pii` mode, OPF failures fall back to built-in redaction with a warning; in `strict` mode they fail the command.
|
|
191
|
+
|
|
192
|
+
The software-agent task ledger keeps the most recent 200 task records by default.
|
|
193
|
+
|
|
194
|
+
The persistent event log rotates automatically at about 10 MB per file and keeps the current `events.jsonl` plus five rotated files.
|
|
195
|
+
|
|
178
196
|
## Serve Options
|
|
179
197
|
|
|
180
198
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { getOrCreateRelayCredentials } from "./config.js";
|
|
|
6
6
|
import { connectRelay } from "./relay-client.js";
|
|
7
7
|
import { listAgents } from "./list.js";
|
|
8
8
|
import { connect } from "./connect.js";
|
|
9
|
+
import { PrivacyFilterUnavailableError, sanitizeText, } from "./privacy-filter.js";
|
|
10
|
+
import { SoftwareAgentStreamCliRenderer } from "./software-agent-stream-cli.js";
|
|
9
11
|
import { readFileSync } from "fs";
|
|
10
12
|
import { fileURLToPath } from "url";
|
|
11
13
|
import { dirname, join } from "path";
|
|
@@ -24,6 +26,43 @@ function clampPositiveInt(value, fallback, max) {
|
|
|
24
26
|
return fallback;
|
|
25
27
|
return Math.min(parsed, max);
|
|
26
28
|
}
|
|
29
|
+
function parsePrivacyFilterMode(value) {
|
|
30
|
+
if (value === "fast" || value === "pii" || value === "strict")
|
|
31
|
+
return value;
|
|
32
|
+
console.error("--mode must be one of: fast, pii, strict");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
function parsePrivacyFilterBackend(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (value === "fast" || value === "opf")
|
|
39
|
+
return value;
|
|
40
|
+
console.error("--backend must be one of: fast, opf");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
function parseSoftwareAgentEnvPolicy(value) {
|
|
44
|
+
const normalized = (value || "inherit").trim().toLowerCase();
|
|
45
|
+
if (normalized === "inherit" || normalized === "allowlist")
|
|
46
|
+
return normalized;
|
|
47
|
+
console.error("--software-agent-env must be one of: inherit, allowlist");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
function parseCommaSeparatedCliOption(value) {
|
|
51
|
+
if (!value)
|
|
52
|
+
return undefined;
|
|
53
|
+
const items = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
54
|
+
return items.length ? items : undefined;
|
|
55
|
+
}
|
|
56
|
+
function parsePositiveIntCliOption(value, optionName) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
60
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
61
|
+
console.error(`${optionName} must be a positive integer`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
27
66
|
function printSoftwareAgentTaskList(tasks) {
|
|
28
67
|
if (!tasks.length) {
|
|
29
68
|
console.log("No software-agent tasks found.");
|
|
@@ -117,6 +156,7 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
|
|
|
117
156
|
const decoder = new TextDecoder();
|
|
118
157
|
let buffer = "";
|
|
119
158
|
let failed = false;
|
|
159
|
+
const streamRenderer = new SoftwareAgentStreamCliRenderer();
|
|
120
160
|
const reader = res.body.getReader();
|
|
121
161
|
while (true) {
|
|
122
162
|
const { done, value } = await reader.read();
|
|
@@ -126,54 +166,16 @@ async function streamLocalOwnerEndpoint(path, opts, body) {
|
|
|
126
166
|
const lines = buffer.split(/\r?\n/);
|
|
127
167
|
buffer = lines.pop() || "";
|
|
128
168
|
for (const line of lines) {
|
|
129
|
-
if (
|
|
169
|
+
if (streamRenderer.handleLine(line))
|
|
130
170
|
failed = true;
|
|
131
171
|
}
|
|
132
172
|
}
|
|
133
173
|
buffer += decoder.decode();
|
|
134
|
-
if (buffer.trim() &&
|
|
174
|
+
if (buffer.trim() && streamRenderer.handleLine(buffer))
|
|
135
175
|
failed = true;
|
|
136
176
|
if (failed)
|
|
137
177
|
process.exit(1);
|
|
138
178
|
}
|
|
139
|
-
function handleSoftwareAgentStreamLine(line) {
|
|
140
|
-
const trimmed = line.trim();
|
|
141
|
-
if (!trimmed)
|
|
142
|
-
return false;
|
|
143
|
-
let event;
|
|
144
|
-
try {
|
|
145
|
-
event = JSON.parse(trimmed);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
process.stderr.write(`${trimmed}\n`);
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
|
-
if (event.type === "start" && event.taskId) {
|
|
152
|
-
process.stderr.write(`[software-agent] started ${event.taskId}\n`);
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
if (event.type === "stdout" && typeof event.chunk === "string") {
|
|
156
|
-
process.stdout.write(event.chunk);
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
if (event.type === "stderr" && typeof event.chunk === "string") {
|
|
160
|
-
process.stderr.write(event.chunk);
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
if (event.type === "end") {
|
|
164
|
-
const result = event.result;
|
|
165
|
-
if (result?.success === false && result.error) {
|
|
166
|
-
process.stderr.write(`${result.error}\n`);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
if (event.type === "error") {
|
|
172
|
-
process.stderr.write(`${event.error || "Software-agent stream failed"}\n`);
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
return false;
|
|
176
|
-
}
|
|
177
179
|
program
|
|
178
180
|
.name("akemon")
|
|
179
181
|
.description("Agent work marketplace — train your agent, let it work for others")
|
|
@@ -202,6 +204,8 @@ program
|
|
|
202
204
|
.option("--without <modules>", "Disable specific modules (comma-separated: biostate,memory)")
|
|
203
205
|
.option("--script <name>", "Script to load for ScriptModule (default: daily-life)", "daily-life")
|
|
204
206
|
.option("--terminal", "Enable remote terminal access (PTY)")
|
|
207
|
+
.option("--software-agent-env <policy>", "Software-agent child environment policy: inherit or allowlist", process.env.AKEMON_SOFTWARE_AGENT_ENV_POLICY || "inherit")
|
|
208
|
+
.option("--software-agent-env-allow <vars>", "Comma-separated extra env vars for software-agent allowlist")
|
|
205
209
|
.option("--relay <url>", "Relay WebSocket URL", RELAY_WS)
|
|
206
210
|
.action(async (opts) => {
|
|
207
211
|
const port = parseInt(opts.port);
|
|
@@ -237,6 +241,8 @@ program
|
|
|
237
241
|
notifyUrl: opts.notify,
|
|
238
242
|
enabledModules,
|
|
239
243
|
scriptName: opts.script,
|
|
244
|
+
softwareAgentEnvPolicy: parseSoftwareAgentEnvPolicy(opts.softwareAgentEnv),
|
|
245
|
+
softwareAgentEnvAllowlist: parseCommaSeparatedCliOption(opts.softwareAgentEnvAllow),
|
|
240
246
|
});
|
|
241
247
|
console.log(`\nakemon v${pkg.version}`);
|
|
242
248
|
if (!opts.public) {
|
|
@@ -303,6 +309,7 @@ program
|
|
|
303
309
|
.option("--memory-scope <scope>", "Memory scope: none|public|task|owner", "owner")
|
|
304
310
|
.option("--risk <level>", "Risk level: low|medium|high", "medium")
|
|
305
311
|
.option("--memory-summary <text>", "Pre-filtered memory/context text to include")
|
|
312
|
+
.option("--session <id>", "Akemon-side context session id for explicit software-agent continuity")
|
|
306
313
|
.option("--deliverable <text>", "Expected output shape")
|
|
307
314
|
.option("--timeout-ms <ms>", "Task timeout in milliseconds")
|
|
308
315
|
.option("--no-stream", "Disable local streaming and wait for the final response")
|
|
@@ -319,6 +326,8 @@ program
|
|
|
319
326
|
body.allowOutsideWorkdir = true;
|
|
320
327
|
if (opts.memorySummary)
|
|
321
328
|
body.memorySummary = opts.memorySummary;
|
|
329
|
+
if (opts.session)
|
|
330
|
+
body.contextSessionId = opts.session;
|
|
322
331
|
if (opts.deliverable)
|
|
323
332
|
body.deliverable = opts.deliverable;
|
|
324
333
|
if (opts.timeoutMs) {
|
|
@@ -382,6 +391,46 @@ program
|
|
|
382
391
|
});
|
|
383
392
|
console.log(JSON.stringify(data, null, 2));
|
|
384
393
|
});
|
|
394
|
+
program
|
|
395
|
+
.command("privacy-filter")
|
|
396
|
+
.description("Sanitize text with built-in redaction and optional OpenAI Privacy Filter")
|
|
397
|
+
.argument("<text...>", "Text to sanitize")
|
|
398
|
+
.option("--mode <mode>", "Mode: fast, pii, or strict", "fast")
|
|
399
|
+
.option("--backend <backend>", "Backend: fast or opf")
|
|
400
|
+
.option("--command <command>", "OPF command (default: opf)")
|
|
401
|
+
.option("--device <device>", "OPF device, e.g. cpu or cuda")
|
|
402
|
+
.option("--checkpoint <path>", "OPF checkpoint directory")
|
|
403
|
+
.option("--timeout-ms <ms>", "OPF timeout in milliseconds")
|
|
404
|
+
.option("--max-input-chars <n>", "Maximum text length to pass to OPF")
|
|
405
|
+
.option("--json", "Print result metadata as JSON")
|
|
406
|
+
.action(async (textParts, opts) => {
|
|
407
|
+
try {
|
|
408
|
+
const result = await sanitizeText(textParts.join(" "), {
|
|
409
|
+
mode: parsePrivacyFilterMode(opts.mode),
|
|
410
|
+
backend: parsePrivacyFilterBackend(opts.backend),
|
|
411
|
+
command: opts.command,
|
|
412
|
+
device: opts.device,
|
|
413
|
+
checkpoint: opts.checkpoint,
|
|
414
|
+
timeoutMs: parsePositiveIntCliOption(opts.timeoutMs, "--timeout-ms"),
|
|
415
|
+
maxInputChars: parsePositiveIntCliOption(opts.maxInputChars, "--max-input-chars"),
|
|
416
|
+
});
|
|
417
|
+
if (opts.json) {
|
|
418
|
+
console.log(JSON.stringify(result, null, 2));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
console.log(result.text);
|
|
422
|
+
for (const warning of result.warnings) {
|
|
423
|
+
console.error(`[privacy-filter] ${warning}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (error instanceof PrivacyFilterUnavailableError || error instanceof TypeError) {
|
|
428
|
+
console.error(error.message);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
});
|
|
385
434
|
program
|
|
386
435
|
.command("dashboard")
|
|
387
436
|
.description("Open your agent dashboard in the browser")
|
|
@@ -17,7 +17,7 @@ import { callAgent, sendTaskEnd, sendTaskStart, sendTaskStream } from "./relay-c
|
|
|
17
17
|
import { SIG, sig } from "./types.js";
|
|
18
18
|
import { updateMetrics, pushExecMs } from "./metrics.js";
|
|
19
19
|
import { sendFailureEvent } from "./relay-client.js";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveEngineRoute, } from "./engine-routing.js";
|
|
21
21
|
export const LLM_ENGINES = new Set(["claude", "codex", "opencode", "gemini", "raw"]);
|
|
22
22
|
const defaultTaskRelay = {
|
|
23
23
|
sendTaskStart,
|
|
@@ -100,11 +100,12 @@ export class EnginePeripheral {
|
|
|
100
100
|
// ---------------------------------------------------------------------------
|
|
101
101
|
// Unified engine runner
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
|
-
async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId) {
|
|
104
|
-
const
|
|
103
|
+
async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing, taskId, routeRequest) {
|
|
104
|
+
const resolution = resolveEngineRoute(routing, { origin, ...routeRequest });
|
|
105
|
+
const entry = resolution.entry;
|
|
105
106
|
const cfg = entry ? applyRoutingEntry(this.config, entry) : this.config;
|
|
106
107
|
if (origin && entry) {
|
|
107
|
-
console.log(`[engine] using ${cfg.engine}${cfg.model ? `/${cfg.model}` : ""} (origin=${origin})`);
|
|
108
|
+
console.log(`[engine] using ${cfg.engine}${cfg.model ? `/${cfg.model}` : ""} (origin=${origin}, source=${resolution.source})`);
|
|
108
109
|
}
|
|
109
110
|
const t0 = Date.now();
|
|
110
111
|
try {
|
package/dist/engine-routing.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* deriveChildOrigin — returns the origin a child/sub-task should carry
|
|
7
7
|
* downgradeForRetry — downgrades any origin to "retry" when a task retries
|
|
8
8
|
*/
|
|
9
|
+
export class EngineRegistry {
|
|
10
|
+
routing;
|
|
11
|
+
constructor(routing) {
|
|
12
|
+
this.routing = routing;
|
|
13
|
+
}
|
|
14
|
+
resolve(request = {}) {
|
|
15
|
+
return resolveEngineRoute(this.routing, request);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* Resolve which engine routing entry to use for a given origin.
|
|
11
20
|
*
|
|
@@ -27,6 +36,34 @@ export function resolveEngineConfig(routing, origin) {
|
|
|
27
36
|
}
|
|
28
37
|
return routing.default ?? null;
|
|
29
38
|
}
|
|
39
|
+
export function resolveEngineRoute(routing, request = {}) {
|
|
40
|
+
if (!routing) {
|
|
41
|
+
return { entry: null, source: "none", reason: "no routing configured" };
|
|
42
|
+
}
|
|
43
|
+
const route = selectRoute(routing.routes, request);
|
|
44
|
+
if (route) {
|
|
45
|
+
return {
|
|
46
|
+
entry: stripRouteMetadata(route),
|
|
47
|
+
source: "route",
|
|
48
|
+
reason: "matched registry route",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (request.origin && routing[request.origin]) {
|
|
52
|
+
return {
|
|
53
|
+
entry: routing[request.origin],
|
|
54
|
+
source: "origin",
|
|
55
|
+
reason: `matched legacy origin route ${request.origin}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (routing.default) {
|
|
59
|
+
return {
|
|
60
|
+
entry: routing.default,
|
|
61
|
+
source: "default",
|
|
62
|
+
reason: "matched legacy default route",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { entry: null, source: "none", reason: "no matching route" };
|
|
66
|
+
}
|
|
30
67
|
/**
|
|
31
68
|
* Derive the origin that a child task should carry.
|
|
32
69
|
*
|
|
@@ -50,3 +87,65 @@ export function deriveChildOrigin(_parentOrigin) {
|
|
|
50
87
|
export function downgradeForRetry(_origin) {
|
|
51
88
|
return "retry";
|
|
52
89
|
}
|
|
90
|
+
function selectRoute(routes, request) {
|
|
91
|
+
if (!routes?.length)
|
|
92
|
+
return null;
|
|
93
|
+
let best = null;
|
|
94
|
+
for (let index = 0; index < routes.length; index++) {
|
|
95
|
+
const route = routes[index];
|
|
96
|
+
if (!routeMatches(route, request))
|
|
97
|
+
continue;
|
|
98
|
+
const score = scoreRoute(route, request);
|
|
99
|
+
if (!best || score > best.score || (score === best.score && index < best.index)) {
|
|
100
|
+
best = { route, score, index };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best?.route ?? null;
|
|
104
|
+
}
|
|
105
|
+
function routeMatches(route, request) {
|
|
106
|
+
if (request.origin && route.origins?.length && !route.origins.includes(request.origin))
|
|
107
|
+
return false;
|
|
108
|
+
if (!hasRequiredCapabilities(route.capabilities, request.requiredCapabilities))
|
|
109
|
+
return false;
|
|
110
|
+
if (request.privacy && route.privacy && route.privacy !== request.privacy)
|
|
111
|
+
return false;
|
|
112
|
+
if (request.maxCost && route.cost && tierRank(route.cost) > tierRank(request.maxCost))
|
|
113
|
+
return false;
|
|
114
|
+
if (request.maxLatency && route.latency && tierRank(route.latency) > tierRank(request.maxLatency))
|
|
115
|
+
return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
function hasRequiredCapabilities(available, required) {
|
|
119
|
+
if (!required?.length)
|
|
120
|
+
return true;
|
|
121
|
+
if (!available?.length)
|
|
122
|
+
return false;
|
|
123
|
+
return required.every((capability) => available.includes(capability));
|
|
124
|
+
}
|
|
125
|
+
function scoreRoute(route, request) {
|
|
126
|
+
let score = route.priority ?? 0;
|
|
127
|
+
if (request.origin && route.origins?.includes(request.origin))
|
|
128
|
+
score += 100;
|
|
129
|
+
if (request.origin && !route.origins?.length)
|
|
130
|
+
score += 10;
|
|
131
|
+
if (request.requiredCapabilities?.length)
|
|
132
|
+
score += (route.capabilities?.length || 0) * 2;
|
|
133
|
+
if (request.privacy && route.privacy === request.privacy)
|
|
134
|
+
score += 20;
|
|
135
|
+
if (route.cost)
|
|
136
|
+
score += 6 - tierRank(route.cost);
|
|
137
|
+
if (route.latency)
|
|
138
|
+
score += 6 - tierRank(route.latency);
|
|
139
|
+
return score;
|
|
140
|
+
}
|
|
141
|
+
function stripRouteMetadata(route) {
|
|
142
|
+
const { origins: _origins, priority: _priority, ...entry } = route;
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
function tierRank(tier) {
|
|
146
|
+
switch (tier) {
|
|
147
|
+
case "low": return 1;
|
|
148
|
+
case "medium": return 2;
|
|
149
|
+
case "high": return 3;
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/event-bus.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* SimpleEventBus: in-memory fire-and-forget. Async handlers don't block emitter.
|
|
5
5
|
* PersistentEventBus: wraps SimpleEventBus + append-only jsonl log for crash recovery.
|
|
6
6
|
*/
|
|
7
|
-
import { appendFileSync, readFileSync, writeFileSync,
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
9
|
+
import { redactSecrets } from "./redaction.js";
|
|
8
10
|
export class SimpleEventBus {
|
|
9
11
|
handlers = new Map();
|
|
10
12
|
on(event, handler) {
|
|
@@ -66,19 +68,23 @@ export class SimpleEventBus {
|
|
|
66
68
|
this.handlers.clear();
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
71
|
+
const DEFAULT_EVENT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
72
|
+
const DEFAULT_EVENT_LOG_MAX_FILES = 5;
|
|
72
73
|
export class FileEventLog {
|
|
73
74
|
path;
|
|
74
|
-
|
|
75
|
+
maxBytes;
|
|
76
|
+
maxFiles;
|
|
77
|
+
constructor(path, options = {}) {
|
|
75
78
|
this.path = path;
|
|
79
|
+
this.maxBytes = normalizePositiveInt(options.maxBytes, DEFAULT_EVENT_LOG_MAX_BYTES);
|
|
80
|
+
this.maxFiles = normalizePositiveInt(options.maxFiles, DEFAULT_EVENT_LOG_MAX_FILES);
|
|
76
81
|
if (!existsSync(path))
|
|
77
82
|
writeFileSync(path, "");
|
|
78
83
|
}
|
|
79
84
|
append(event, signal) {
|
|
80
85
|
try {
|
|
81
|
-
const line = JSON.stringify({ e: event, s: signal }) + "\n";
|
|
86
|
+
const line = JSON.stringify(redactSecrets({ e: event, s: signal })) + "\n";
|
|
87
|
+
this.rotateIfNeeded(Buffer.byteLength(line, "utf8"));
|
|
82
88
|
appendFileSync(this.path, line);
|
|
83
89
|
}
|
|
84
90
|
catch {
|
|
@@ -86,24 +92,64 @@ export class FileEventLog {
|
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
async replay(handler) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const content = readFileSync(this.path, "utf-8");
|
|
92
|
-
for (const line of content.split("\n")) {
|
|
93
|
-
if (!line.trim())
|
|
95
|
+
for (const file of this.replayPaths()) {
|
|
96
|
+
if (!existsSync(file))
|
|
94
97
|
continue;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
const content = readFileSync(file, "utf-8");
|
|
99
|
+
for (const line of content.split("\n")) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
const { e, s } = JSON.parse(line);
|
|
104
|
+
handler(e, s);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Skip corrupted lines
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
close() {
|
|
105
113
|
// No-op for sync file writes
|
|
106
114
|
}
|
|
115
|
+
rotateIfNeeded(incomingBytes) {
|
|
116
|
+
if (this.maxBytes <= 0)
|
|
117
|
+
return;
|
|
118
|
+
if (!existsSync(this.path)) {
|
|
119
|
+
writeFileSync(this.path, "");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const currentBytes = statSync(this.path).size;
|
|
123
|
+
if (currentBytes <= 0 || currentBytes + incomingBytes <= this.maxBytes)
|
|
124
|
+
return;
|
|
125
|
+
const oldest = this.rotatedPath(this.maxFiles);
|
|
126
|
+
if (existsSync(oldest))
|
|
127
|
+
unlinkSync(oldest);
|
|
128
|
+
for (let index = this.maxFiles - 1; index >= 1; index--) {
|
|
129
|
+
const from = this.rotatedPath(index);
|
|
130
|
+
if (existsSync(from))
|
|
131
|
+
renameSync(from, this.rotatedPath(index + 1));
|
|
132
|
+
}
|
|
133
|
+
renameSync(this.path, this.rotatedPath(1));
|
|
134
|
+
writeFileSync(this.path, "");
|
|
135
|
+
}
|
|
136
|
+
replayPaths() {
|
|
137
|
+
const paths = [];
|
|
138
|
+
for (let index = this.maxFiles; index >= 1; index--) {
|
|
139
|
+
paths.push(this.rotatedPath(index));
|
|
140
|
+
}
|
|
141
|
+
paths.push(this.path);
|
|
142
|
+
return paths;
|
|
143
|
+
}
|
|
144
|
+
rotatedPath(index) {
|
|
145
|
+
const dir = dirname(this.path);
|
|
146
|
+
const ext = extname(this.path);
|
|
147
|
+
const base = basename(this.path, ext);
|
|
148
|
+
return join(dir, `${base}.${index}${ext}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function normalizePositiveInt(value, fallback) {
|
|
152
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
107
153
|
}
|
|
108
154
|
// ---------------------------------------------------------------------------
|
|
109
155
|
// PersistentEventBus — SimpleEventBus + append-only log
|