ksl-tender-events / tree / src/events.ts ยท raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
// KSL tender events: an append only, validated JSONL stream of every tender step, and a daily report section.
// emit(event) appends one line; reportSection(events, day) returns Markdown. No secrets: unknown fields are refused.
import { createHash } from 'node:crypto';
import { appendFileSync, existsSync, readFileSync } from 'node:fs';

export const STEPS = ['listing', 'airlock_decision', 'bid', 'selection', 'award', 'delivery', 'review', 'payment'] as const;
export type Step = (typeof STEPS)[number];

interface Base { job_id: string; at: string }
export type TenderEvent =
  | (Base & { type: 'listing'; title?: string; ceiling_usdc?: number })
  | (Base & { type: 'airlock_decision'; bid_id: string; decision: 'pass' | 'block'; note?: string })
  | (Base & { type: 'bid'; bid_id: string; agent_did: string; price_usdc: number })
  | (Base & { type: 'selection'; bid_id: string })
  | (Base & { type: 'award'; bid_id: string })
  | (Base & { type: 'delivery'; bid_id: string; url: string })
  | (Base & { type: 'review'; bid_id: string; verdict: 'accepted' | 'rejected'; note?: string })
  | (Base & { type: 'payment'; bid_id: string; amount_usdc: number; rail?: 'x402' });

/** One stored line: the event plus its place in the chain. */
export type StoredEvent = TenderEvent & { seq: number; prev: string; hash: string };

type FieldKind = 'id' | 'did' | 'usdc' | 'url' | 'text' | ['enum', ...string[]];
const FIELDS: Record<Step, Record<string, [FieldKind, boolean]>> = {
  listing: { title: ['text', false], ceiling_usdc: ['usdc', false] },
  airlock_decision: { bid_id: ['id', true], decision: [['enum', 'pass', 'block'], true], note: ['text', false] },
  bid: { bid_id: ['id', true], agent_did: ['did', true], price_usdc: ['usdc', true] },
  selection: { bid_id: ['id', true] },
  award: { bid_id: ['id', true] },
  delivery: { bid_id: ['id', true], url: ['url', true] },
  review: { bid_id: ['id', true], verdict: [['enum', 'accepted', 'rejected'], true], note: ['text', false] },
  payment: { bid_id: ['id', true], amount_usdc: ['usdc', true], rail: [['enum', 'x402'], false] },
};
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;

export class EventError extends Error {}

/** Throws EventError naming the first problem; returns the event unchanged when it is valid. */
export function validate(e: unknown): TenderEvent {
  if (!e || typeof e !== 'object' || Array.isArray(e)) throw new EventError('event must be an object');
  const ev = e as Record<string, unknown>;
  if (!STEPS.includes(ev.type as Step)) throw new EventError(`type must be one of ${STEPS.join(', ')}`);
  const spec = FIELDS[ev.type as Step];
  if (typeof ev.job_id !== 'string' || !ID_RE.test(ev.job_id)) throw new EventError('job_id is missing or malformed');
  if (typeof ev.at !== 'string' || !ISO_RE.test(ev.at) || !Number.isFinite(Date.parse(ev.at))) throw new EventError('at must be an ISO date-time with a zone');
  for (const k of Object.keys(ev)) if (!(k in spec) && !['type', 'job_id', 'at'].includes(k)) throw new EventError(`unknown field ${k} on ${ev.type}`);
  for (const [k, [kind, required]] of Object.entries(spec)) {
    const v = ev[k];
    if (v === undefined) {
      if (required) throw new EventError(`${ev.type} needs ${k}`);
      continue;
    }
    const bad = () => new EventError(`${k} on ${ev.type} is not valid`);
    if (Array.isArray(kind)) {
      if (!kind.slice(1).includes(v as string)) throw bad();
    } else if (kind === 'id') {
      if (typeof v !== 'string' || !ID_RE.test(v)) throw bad();
    } else if (kind === 'did') {
      if (typeof v !== 'string' || !/^did:[a-z0-9]+:.{1,200}$/.test(v)) throw bad();
    } else if (kind === 'usdc') {
      if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1e9) throw bad();
    } else if (kind === 'url') {
      if (typeof v !== 'string' || !/^https:\/\/\S+$/.test(v)) throw bad();
    } else if (typeof v !== 'string' || v.length > 500) throw bad();
  }
  return ev as unknown as TenderEvent;
}

const GENESIS = '0'.repeat(64);
const canonical = (o: Record<string, unknown>) => JSON.stringify(Object.keys(o).sort().reduce<Record<string, unknown>>((a, k) => ((a[k] = o[k]), a), {}));
const hashOf = (event: TenderEvent, seq: number, prev: string) => createHash('sha256').update(`${seq}\n${prev}\n${canonical(event as unknown as Record<string, unknown>)}`).digest('hex');

/** Reads a JSONL stream and checks the chain: every line in order, none changed, none removed. */
export function readStream(path: string): StoredEvent[] {
  if (!existsSync(path)) return [];
  const lines = readFileSync(path, 'utf8').split('\n').filter((l) => l.trim());
  let prev = GENESIS;
  return lines.map((line, i) => {
    let s: StoredEvent;
    try {
      s = JSON.parse(line);
    } catch {
      throw new EventError(`line ${i + 1} is not JSON`);
    }
    const { seq, prev: p, hash, ...event } = s;
    if (seq !== i + 1 || p !== prev) throw new EventError(`line ${i + 1} is out of order or a line before it was removed`);
    validate(event);
    if (hash !== hashOf(event as TenderEvent, seq, p)) throw new EventError(`line ${i + 1} was changed after it was written`);
    prev = hash;
    return s;
  });
}

export interface EventLog {
  emit(event: TenderEvent): StoredEvent;
  read(): StoredEvent[];
  readonly path: string;
}

/** Opens (or creates) an append only stream. The file is only ever appended to. */
export function openEventLog(path: string): EventLog {
  let last = readStream(path);
  let seq = last.length;
  let prev = seq ? last[seq - 1].hash : GENESIS;
  last = [];
  return {
    path,
    emit(event) {
      const ev = validate(structuredClone(event));
      const s = seq + 1;
      const hash = hashOf(ev, s, prev);
      const stored = { ...ev, seq: s, prev, hash } as StoredEvent;
      appendFileSync(path, JSON.stringify(stored) + '\n', { flag: 'a' });
      seq = s;
      prev = hash;
      return stored;
    },
    read: () => readStream(path),
  };
}

let defaultLog: EventLog | null = null;
/** emit(event) on the stream named by KSL_EVENTS_PATH (default ./tender-events.jsonl). */
export function emit(event: TenderEvent): StoredEvent {
  const path = process.env.KSL_EVENTS_PATH ?? 'tender-events.jsonl';
  if (!defaultLog || defaultLog.path !== path) defaultLog = openEventLog(path);
  return defaultLog.emit(event);
}

const LABEL: Record<Step, string> = {
  listing: 'Listings', airlock_decision: 'Airlock decisions', bid: 'Bids', selection: 'Selections', award: 'Awards', delivery: 'Deliveries', review: 'Reviews', payment: 'Payments',
};
const dayOfIso = (at: string) => new Date(Date.parse(at)).toISOString().slice(0, 10);
const money = (n: number) => (Math.round(n * 100) / 100).toFixed(2);

export interface Anomaly { job_id: string; bid_id: string; problem: string }

/** Anomalies in every event up to the end of the day (UTC), so a decision made on an earlier day counts. */
export function anomalies(events: TenderEvent[], day?: string): Anomaly[] {
  const until = day ? Date.parse(`${day}T23:59:59.999Z`) : Infinity;
  const evs = events.filter((e) => Date.parse(e.at) <= until);
  const key = (e: { job_id: string; bid_id: string }) => `${e.job_id}\u0000${e.bid_id}`;
  const by = new Map<Step, Map<string, TenderEvent>>(STEPS.map((s) => [s, new Map()]));
  for (const e of evs) if ('bid_id' in e) by.get(e.type)!.set(key(e), e);
  const out: Anomaly[] = [];
  const add = (e: TenderEvent & { bid_id: string }, problem: string) => out.push({ job_id: e.job_id, bid_id: e.bid_id, problem });
  for (const e of evs) {
    if (!('bid_id' in e)) continue;
    const k = key(e);
    const airlock = by.get('airlock_decision')!.get(k) as Extract<TenderEvent, { type: 'airlock_decision' }> | undefined;
    const review = by.get('review')!.get(k) as Extract<TenderEvent, { type: 'review' }> | undefined;
    if (e.type === 'bid' && !airlock) add(e, 'bid without an airlock decision');
    if (e.type === 'selection' && airlock?.decision === 'block') add(e, 'selection of a bid the airlock blocked');
    if (e.type === 'selection' && !by.get('bid')!.has(k)) add(e, 'selection of a bid that was never recorded');
    if (e.type === 'award' && !by.get('selection')!.has(k)) add(e, 'award without a selection');
    if (e.type === 'delivery' && !by.get('award')!.has(k)) add(e, 'delivery without an award');
    if (e.type === 'review' && !by.get('delivery')!.has(k)) add(e, 'review without a delivery');
    if (e.type === 'payment' && review?.verdict !== 'accepted') add(e, review ? 'payment after a rejected review' : 'payment without acceptance');
    if (e.type === 'payment') {
      const bid = by.get('bid')!.get(k) as Extract<TenderEvent, { type: 'bid' }> | undefined;
      if (bid && e.amount_usdc > bid.price_usdc) add(e, `payment of ${money(e.amount_usdc)} USDC is over the bid price of ${money(bid.price_usdc)} USDC`);
    }
  }
  const seen = new Set<string>();
  return out
    .filter((a) => (seen.has(JSON.stringify(a)) ? false : (seen.add(JSON.stringify(a)), true)))
    .sort((a, b) => (a.job_id + a.bid_id + a.problem < b.job_id + b.bid_id + b.problem ? -1 : 1));
}

/** The daily report section: counts of each step on that day (UTC), USDC paid, and the open anomalies. */
export function reportSection(events: TenderEvent[], day: string): string {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw new EventError('day must be YYYY-MM-DD');
  const today = events.filter((e) => dayOfIso(e.at) === day);
  const count = (s: Step) => today.filter((e) => e.type === s).length;
  const blocked = today.filter((e) => e.type === 'airlock_decision' && e.decision === 'block').length;
  const rejected = today.filter((e) => e.type === 'review' && e.verdict === 'rejected').length;
  const paid = today.reduce((t, e) => t + (e.type === 'payment' ? e.amount_usdc : 0), 0);
  const jobs = new Set(today.map((e) => e.job_id)).size;
  const lines = [
    `### Tenders on ${day}`,
    '',
    `${today.length} events across ${jobs} ${jobs === 1 ? 'job' : 'jobs'}; ${money(paid)} USDC paid.`,
    '',
    '| Step | Count |',
    '| --- | --- |',
    ...STEPS.map((s) => {
      const extra = s === 'airlock_decision' && blocked ? ` (${blocked} blocked)` : s === 'review' && rejected ? ` (${rejected} rejected)` : '';
      return `| ${LABEL[s]} | ${count(s)}${extra} |`;
    }),
    '',
  ];
  const found = anomalies(events, day);
  if (!found.length) lines.push('No open anomalies at the end of the day.');
  else {
    lines.push(`**Open anomalies at the end of the day (${found.length})**`, '');
    for (const a of found) lines.push(`- ${a.job_id} / ${a.bid_id}: ${a.problem}`);
  }
  return lines.join('\n') + '\n';
}

agentgate-source: code mirror on Cloudflare Pages. All repos