import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openEventLog, readStream, validate, reportSection, anomalies, emit, EventError, STEPS, type TenderEvent } from '../src/events.js';
const tmp = () => join(mkdtempSync(join(tmpdir(), 'ksl-')), 'events.jsonl');
const D = '2026-10-21';
const at = (h: number) => `${D}T${String(h).padStart(2, '0')}:00:00Z`;
const happy: TenderEvent[] = [
{ type: 'listing', job_id: 'j1', at: at(1), title: 'Scorer', ceiling_usdc: 50 },
{ type: 'bid', job_id: 'j1', at: at(2), bid_id: 'b1', agent_did: 'did:pkh:eip155:8453:a', price_usdc: 40 },
{ type: 'airlock_decision', job_id: 'j1', at: at(3), bid_id: 'b1', decision: 'pass' },
{ type: 'selection', job_id: 'j1', at: at(4), bid_id: 'b1' },
{ type: 'award', job_id: 'j1', at: at(5), bid_id: 'b1' },
{ type: 'delivery', job_id: 'j1', at: at(6), bid_id: 'b1', url: 'https://github.com/a/b' },
{ type: 'review', job_id: 'j1', at: at(7), bid_id: 'b1', verdict: 'accepted' },
{ type: 'payment', job_id: 'j1', at: at(8), bid_id: 'b1', amount_usdc: 40, rail: 'x402' },
];
test('A1 emit appends one validated line per event and read gives them back in order', () => {
const p = tmp();
const log = openEventLog(p);
happy.forEach((e) => log.emit(e));
const back = log.read();
assert.equal(back.length, 8);
assert.deepEqual(back.map((e) => e.seq), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.deepEqual(back.map((e) => e.type), [...STEPS.slice(0, 1), 'bid', 'airlock_decision', ...STEPS.slice(3)]);
assert.equal(readFileSync(p, 'utf8').trim().split('\n').length, 8);
});
test('A1 append only: reopening continues the chain, and earlier lines are never rewritten', () => {
const p = tmp();
openEventLog(p).emit(happy[0]);
const before = readFileSync(p, 'utf8');
openEventLog(p).emit(happy[1]);
const after = readFileSync(p, 'utf8');
assert.ok(after.startsWith(before));
assert.equal(readStream(p)[1].prev, readStream(p)[0].hash);
});
test('A1 a changed, removed or reordered line is detected', () => {
const p = tmp();
const log = openEventLog(p);
happy.slice(0, 3).forEach((e) => log.emit(e));
const lines = readFileSync(p, 'utf8').trim().split('\n');
writeFileSync(p, lines[0] + '\n' + lines[1].replace('"price_usdc":40', '"price_usdc":4') + '\n' + lines[2] + '\n');
assert.throws(() => readStream(p), /changed after it was written/);
writeFileSync(p, lines[0] + '\n' + lines[2] + '\n');
assert.throws(() => readStream(p), /removed/);
writeFileSync(p, lines[1] + '\n' + lines[0] + '\n');
assert.throws(() => readStream(p), /out of order/);
assert.throws(() => openEventLog(p), EventError, 'a broken stream is not appended to');
});
test('A1 validation refuses bad events and nothing is written', () => {
const p = tmp();
const log = openEventLog(p);
const bad: unknown[] = [
null,
{ type: 'tweet', job_id: 'j1', at: at(1) },
{ type: 'bid', job_id: 'j1', at: at(1), bid_id: 'b1', price_usdc: 5 },
{ type: 'bid', job_id: 'j1', at: at(1), bid_id: 'b1', agent_did: 'did:a:b', price_usdc: -1 },
{ type: 'bid', job_id: 'j1', at: '2026-10-21', bid_id: 'b1', agent_did: 'did:a:b', price_usdc: 5 },
{ type: 'airlock_decision', job_id: 'j1', at: at(1), bid_id: 'b1', decision: 'maybe' },
{ type: 'delivery', job_id: 'j1', at: at(1), bid_id: 'b1', url: 'http://x.y' },
{ type: 'payment', job_id: 'j1', at: at(1), bid_id: 'b1', amount_usdc: 5, contact: 'me@example.com' },
{ type: 'listing', job_id: '', at: at(1) },
];
for (const e of bad) assert.throws(() => log.emit(e as TenderEvent), EventError, JSON.stringify(e));
assert.deepEqual(readStream(p), []);
assert.equal(validate(happy[1]), happy[1]);
});
test('A1 emitted events are copies: changing the object later does not change the stream', () => {
const p = tmp();
const log = openEventLog(p);
const e = { ...happy[1] } as Extract<TenderEvent, { type: 'bid' }>;
log.emit(e);
e.price_usdc = 1;
assert.equal((log.read()[0] as typeof e).price_usdc, 40);
});
test('A1 emit(event) writes to KSL_EVENTS_PATH', () => {
const p = tmp();
process.env.KSL_EVENTS_PATH = p;
emit(happy[0]);
emit(happy[1]);
assert.equal(readStream(p).length, 2);
delete process.env.KSL_EVENTS_PATH;
});
test('A2 the report counts each step for the day and shows no anomalies on a clean tender', () => {
const md = reportSection(happy, D);
assert.match(md, /### Tenders on 2026-10-21/);
assert.match(md, /8 events across 1 job; 40\.00 USDC paid\./);
for (const label of ['Listings', 'Airlock decisions', 'Bids', 'Selections', 'Awards', 'Deliveries', 'Reviews', 'Payments']) assert.match(md, new RegExp(`\\| ${label} \\| 1 \\|`));
assert.match(md, /No open anomalies/);
assert.ok(!/[\u2013\u2014]/.test(md));
});
test('A3 anomalies: a bid without an airlock decision, and a payment without acceptance', () => {
const evs: TenderEvent[] = [
...happy,
{ type: 'bid', job_id: 'j1', at: at(9), bid_id: 'b2', agent_did: 'did:a:b', price_usdc: 30 },
{ type: 'payment', job_id: 'j2', at: at(10), bid_id: 'b9', amount_usdc: 10 },
];
const md = reportSection(evs, D);
assert.match(md, /Open anomalies at the end of the day \(2\)/);
assert.match(md, /- j1 \/ b2: bid without an airlock decision/);
assert.match(md, /- j2 \/ b9: payment without acceptance/);
});
test('A3 more anomalies: blocked selection, rejected then paid, overpayment, skipped steps', () => {
const evs: TenderEvent[] = [
{ type: 'bid', job_id: 'j3', at: at(1), bid_id: 'x', agent_did: 'did:a:b', price_usdc: 20 },
{ type: 'airlock_decision', job_id: 'j3', at: at(2), bid_id: 'x', decision: 'block' },
{ type: 'selection', job_id: 'j3', at: at(3), bid_id: 'x' },
{ type: 'review', job_id: 'j3', at: at(4), bid_id: 'x', verdict: 'rejected' },
{ type: 'payment', job_id: 'j3', at: at(5), bid_id: 'x', amount_usdc: 25 },
];
const problems = anomalies(evs).map((a) => a.problem);
assert.ok(problems.includes('selection of a bid the airlock blocked'));
assert.ok(problems.includes('payment after a rejected review'));
assert.ok(problems.includes('payment of 25.00 USDC is over the bid price of 20.00 USDC'));
assert.ok(problems.includes('review without a delivery'));
assert.match(reportSection(evs, D), /Airlock decisions \| 1 \(1 blocked\)/);
});
test('A3 a decision on a later day clears the anomaly from that day on, but not before', () => {
const evs: TenderEvent[] = [
{ type: 'bid', job_id: 'j1', at: '2026-10-20T23:00:00Z', bid_id: 'b', agent_did: 'did:a:b', price_usdc: 1 },
{ type: 'airlock_decision', job_id: 'j1', at: '2026-10-21T01:00:00Z', bid_id: 'b', decision: 'pass' },
];
assert.match(reportSection(evs, '2026-10-20'), /bid without an airlock decision/);
assert.match(reportSection(evs, '2026-10-21'), /No open anomalies/);
assert.match(reportSection(evs, '2026-10-21'), /\| Bids \| 0 \|/);
});
test('A2 the report is deterministic and refuses a bad day', () => {
assert.equal(reportSection(happy, D), reportSection([...happy].reverse(), D));
assert.throws(() => reportSection(happy, '21/10/2026'), EventError);
});
agentgate-source: code mirror on Cloudflare Pages. All repos