feat(ultraplan-local): add stats event-emit for autonomy lifecycle events
Step 6 of plan-v2 (ultra-pipeline-speedup).
lib/stats/event-emit.mjs (NEW)
Atomic JSONL append to ${CLAUDE_PLUGIN_DATA}/ultraexecute-stats.jsonl.
Every record carries:
ts : ISO-8601 timestamp (REQUIRED per SC4)
event : caller-supplied name
known_event : true for { brief-approved, main-merge-gate, user_input },
false for everything else (still emitted — audit-complete)
payload : caller object (defaults to {})
Stats failures NEVER block workflow: missing CLAUDE_PLUGIN_DATA, missing
dir, mkdir failure, append failure → all return { written: false, reason }
without throwing.
CLI shim:
node lib/stats/event-emit.mjs --event NAME [--payload JSON]
Always exits 0 (telemetry is best-effort).
Tests: 12 (record-build + ISO-8601 ts + known/unknown distinction + silent
skip + dir-on-demand + CLI shim happy-path + bad-payload tolerance +
concurrent-append smoke).
[skip-docs]
This commit is contained in:
parent
bed14eae4a
commit
bbe7971d01
2 changed files with 275 additions and 0 deletions
117
plugins/ultraplan-local/lib/stats/event-emit.mjs
Normal file
117
plugins/ultraplan-local/lib/stats/event-emit.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// lib/stats/event-emit.mjs
|
||||
// Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6).
|
||||
//
|
||||
// Writes one line per event to ${CLAUDE_PLUGIN_DATA}/ultraexecute-stats.jsonl
|
||||
// (or override via CLAUDE_PLUGIN_DATA env var; falls back to silent skip if
|
||||
// the directory doesn't exist — stats failures must NEVER block workflow).
|
||||
//
|
||||
// Every emission carries:
|
||||
// - ts : ISO-8601 timestamp (REQUIRED per SC4 contract)
|
||||
// - event : the requested event name
|
||||
// - known_event : true for recognized events, false otherwise
|
||||
// - payload : caller-supplied object (may be {})
|
||||
//
|
||||
// Recognized events: brief-approved, main-merge-gate, user_input.
|
||||
// Unknown event names are still emitted (with known_event: false) so that
|
||||
// the audit trail is complete; downstream consumers filter as needed.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/stats/event-emit.mjs --event brief-approved --payload '{...}'
|
||||
// → exit 0 (always); silent on stat dir absence.
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export const KNOWN_EVENTS = Object.freeze(new Set([
|
||||
'brief-approved',
|
||||
'main-merge-gate',
|
||||
'user_input',
|
||||
]));
|
||||
|
||||
const STATS_FILENAME = 'ultraexecute-stats.jsonl';
|
||||
|
||||
/**
|
||||
* Resolve the stats file path. Honors CLAUDE_PLUGIN_DATA env var.
|
||||
* Returns null if no plugin-data dir is configured (silent-skip mode).
|
||||
*/
|
||||
export function resolveStatsPath(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dir || typeof dir !== 'string' || dir.length === 0) return null;
|
||||
return join(dir, STATS_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON record. Pure — no I/O.
|
||||
*/
|
||||
export function buildRecord(event, payload = {}, now = new Date()) {
|
||||
if (typeof event !== 'string' || event.length === 0) {
|
||||
throw new TypeError('event must be a non-empty string');
|
||||
}
|
||||
return {
|
||||
ts: now.toISOString(),
|
||||
event,
|
||||
known_event: KNOWN_EVENTS.has(event),
|
||||
payload: (payload && typeof payload === 'object') ? payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event. Never throws — stat failures are swallowed silently
|
||||
* because lifecycle telemetry must not block the user's workflow.
|
||||
*
|
||||
* @returns {{ written: boolean, path: string | null, reason?: string }}
|
||||
*/
|
||||
export function emit(event, payload = {}, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const now = opts.now || new Date();
|
||||
let record;
|
||||
try {
|
||||
record = buildRecord(event, payload, now);
|
||||
} catch (e) {
|
||||
return { written: false, path: null, reason: `record-build: ${e.message}` };
|
||||
}
|
||||
const path = opts.path || resolveStatsPath(env);
|
||||
if (!path) return { written: false, path: null, reason: 'CLAUDE_PLUGIN_DATA unset' };
|
||||
try {
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) {
|
||||
// Best-effort dir creation; if it fails, swallow and skip.
|
||||
try { mkdirSync(dir, { recursive: true }); } catch { return { written: false, path, reason: 'dir-mkdir-failed' }; }
|
||||
}
|
||||
appendFileSync(path, JSON.stringify(record) + '\n');
|
||||
return { written: true, path };
|
||||
} catch (e) {
|
||||
return { written: false, path, reason: `append-failed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--event') out.event = argv[++i];
|
||||
else if (a === '--payload') out.payload = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.event) {
|
||||
process.stdout.write(JSON.stringify({ written: false, reason: 'usage: --event NAME [--payload JSON]' }) + '\n');
|
||||
process.exit(0); // never block: usage error still exits clean
|
||||
}
|
||||
let payload = {};
|
||||
if (args.payload) {
|
||||
try { payload = JSON.parse(args.payload); }
|
||||
catch {
|
||||
process.stdout.write(JSON.stringify({ written: false, reason: 'payload-not-json' }) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
const result = emit(args.event, payload);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
158
plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs
Normal file
158
plugins/ultraplan-local/tests/lib/stats-event-emit.test.mjs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// tests/lib/stats-event-emit.test.mjs
|
||||
// Cover lib/stats/event-emit.mjs:
|
||||
// - emit appends a JSONL line with required ISO-8601 ts
|
||||
// - known_event flag distinguishes recognized vs unknown events
|
||||
// - missing CLAUDE_PLUGIN_DATA does NOT throw (stats must never block)
|
||||
// - CLI shim parses --payload JSON and writes via emit()
|
||||
// - concurrent appends don't corrupt the file (smoke test)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { emit, buildRecord, resolveStatsPath, KNOWN_EVENTS } from '../../lib/stats/event-emit.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'stats', 'event-emit.mjs');
|
||||
|
||||
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function tmp(prefix = 'stats-event-emit-') {
|
||||
return mkdtempSync(join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
test('KNOWN_EVENTS contains plan-v2 spec set', () => {
|
||||
for (const e of ['brief-approved', 'main-merge-gate', 'user_input']) {
|
||||
assert.ok(KNOWN_EVENTS.has(e), `missing recognized event: ${e}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildRecord emits ISO-8601 ts (REQUIRED per SC4)', () => {
|
||||
const r = buildRecord('brief-approved', { foo: 1 });
|
||||
assert.match(r.ts, ISO_8601_RE);
|
||||
assert.equal(r.event, 'brief-approved');
|
||||
assert.equal(r.known_event, true);
|
||||
assert.deepEqual(r.payload, { foo: 1 });
|
||||
});
|
||||
|
||||
test('buildRecord marks unrecognized events known_event: false', () => {
|
||||
const r = buildRecord('totally-made-up-event');
|
||||
assert.equal(r.known_event, false);
|
||||
assert.deepEqual(r.payload, {});
|
||||
});
|
||||
|
||||
test('buildRecord rejects empty event name', () => {
|
||||
assert.throws(() => buildRecord(''), TypeError);
|
||||
assert.throws(() => buildRecord(null), TypeError);
|
||||
});
|
||||
|
||||
test('emit appends one JSONL line per call', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'stats.jsonl');
|
||||
const r1 = emit('brief-approved', { ok: true }, { path });
|
||||
const r2 = emit('main-merge-gate', { branch: 'main' }, { path });
|
||||
assert.equal(r1.written, true);
|
||||
assert.equal(r2.written, true);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
const a = JSON.parse(lines[0]);
|
||||
const b = JSON.parse(lines[1]);
|
||||
assert.match(a.ts, ISO_8601_RE);
|
||||
assert.match(b.ts, ISO_8601_RE);
|
||||
assert.equal(a.event, 'brief-approved');
|
||||
assert.equal(b.event, 'main-merge-gate');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('emit creates the stats directory on demand', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'nested', 'stats.jsonl');
|
||||
const r = emit('user_input', {}, { path });
|
||||
assert.equal(r.written, true);
|
||||
assert.ok(existsSync(path));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('emit with no CLAUDE_PLUGIN_DATA returns { written: false } (silent skip)', () => {
|
||||
const r = emit('brief-approved', {}, { env: {} });
|
||||
assert.equal(r.written, false);
|
||||
assert.equal(r.path, null);
|
||||
assert.match(r.reason, /CLAUDE_PLUGIN_DATA unset/);
|
||||
});
|
||||
|
||||
test('emit never throws when stats path is unwritable', () => {
|
||||
// Pointing at a path under a non-existent dir on a readonly mount would
|
||||
// be brittle in CI; instead, force the env-resolved path to be empty
|
||||
// and confirm no exception leaks.
|
||||
let threw = false;
|
||||
try { emit('user_input', { foo: 'bar' }, { env: {} }); }
|
||||
catch { threw = true; }
|
||||
assert.equal(threw, false);
|
||||
});
|
||||
|
||||
test('resolveStatsPath honors CLAUDE_PLUGIN_DATA env var', () => {
|
||||
const r = resolveStatsPath({ CLAUDE_PLUGIN_DATA: '/var/data/plugin' });
|
||||
assert.equal(r, '/var/data/plugin/ultraexecute-stats.jsonl');
|
||||
assert.equal(resolveStatsPath({}), null);
|
||||
});
|
||||
|
||||
test('CLI shim writes via emit when CLAUDE_PLUGIN_DATA is set', () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
SHIM, '--event', 'brief-approved', '--payload', '{"foo":42}',
|
||||
], {
|
||||
env: { ...process.env, CLAUDE_PLUGIN_DATA: dir },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const path = join(dir, 'ultraexecute-stats.jsonl');
|
||||
assert.ok(existsSync(path));
|
||||
const line = readFileSync(path, 'utf-8').trim();
|
||||
const parsed = JSON.parse(line);
|
||||
assert.equal(parsed.event, 'brief-approved');
|
||||
assert.deepEqual(parsed.payload, { foo: 42 });
|
||||
assert.match(parsed.ts, ISO_8601_RE);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI shim with malformed --payload returns reason payload-not-json (exit 0)', () => {
|
||||
const r = execFileSync(process.execPath, [
|
||||
SHIM, '--event', 'user_input', '--payload', 'not-json{{',
|
||||
], { encoding: 'utf-8' });
|
||||
const parsed = JSON.parse(r.trim());
|
||||
assert.equal(parsed.written, false);
|
||||
assert.equal(parsed.reason, 'payload-not-json');
|
||||
});
|
||||
|
||||
test('concurrent appends do not corrupt JSONL (smoke)', async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const path = join(dir, 'stats.jsonl');
|
||||
const N = 25;
|
||||
await Promise.all(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
Promise.resolve().then(() => emit('user_input', { i }, { path })),
|
||||
),
|
||||
);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
assert.equal(lines.length, N);
|
||||
for (const l of lines) {
|
||||
const parsed = JSON.parse(l); // throws if any line is corrupt
|
||||
assert.ok('ts' in parsed);
|
||||
assert.equal(parsed.event, 'user_input');
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue