SPB Git

Repository Public

timeline.ts 18.8 KB 382 lines typescript

1/**2 * Pure timeline logic: merging journal steps by `seq` (the reason resume can neither duplicate3 * nor lose an item) and turning steps + waits + live state into the flat list the timeline4 * renders. `StepDto.request` / `.result` are `unknown` in the contract, so every read here is5 * defensive; a shape that is not recognised is shown as JSON rather than guessed.6 */7import type { ContentBlock, StepDto, WaitDto } from '@aos/shared';8import { additionsOnly, fileFromHunk, lineDiff, looksLikeUnifiedDiff, parseUnifiedDiff, type DiffFile } from './diff';910export type Step = StepDto & { truncated?: boolean | { request?: boolean; result?: boolean } };11type Obj = Record<string, unknown>;12export const obj = (v: unknown): Obj => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Obj) : {});13const str = (v: unknown): string | null => (typeof v === 'string' ? v : null);14const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null);15const firstStr = (o: Obj, keys: string[]): string | null => {16  for (const k of keys) if (typeof o[k] === 'string') return o[k] as string;17  return null;18};1920/** Merge by seq (incoming wins), sorted ascending. */21export function mergeSteps(current: readonly Step[], incoming: readonly Step[]): Step[] {22  if (incoming.length === 0) return current as Step[];23  const map = new Map<number, Step>();24  for (const s of current) map.set(s.seq, s);25  for (const s of incoming) map.set(s.seq, s);26  return [...map.values()].sort((a, b) => a.seq - b.seq);27}28/** `after` for an incremental fetch: everything from the first step that is not settled yet. */29export function syncCursor(steps: readonly Step[]): number | null {30  if (steps.length === 0) return null;31  const open = steps.find((s) => s.status === 'started' || s.status === 'needs_reconcile');32  return open ? open.seq - 1 : (steps[steps.length - 1]?.seq ?? null);33}34export function isModelStep(s: Step): boolean {35  return s.kind === 'model_call' || s.effect === 'model_call';36}37export function lastCompletedModelSeq(steps: readonly Step[]): number {38  let max = -1;39  for (const s of steps) if (isModelStep(s) && s.status !== 'started' && s.seq > max) max = s.seq;40  return max;41}42export function durationMs(s: Step): number | null {43  if (!s.completedAt) return null;44  const d = new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime();45  return Number.isFinite(d) && d >= 0 ? d : null;46}47export function isTruncated(s: Step): boolean {48  const t = s.truncated;49  if (t === true) return true;50  if (t && typeof t === 'object') return !!(t.request || t.result);51  return obj(s.result).truncated === true || obj(s.request).truncated === true;52}53export function errorText(e: unknown): string {54  if (e == null) return '';55  if (typeof e === 'string') return e;56  const o = obj(e);57  return str(o.message) ?? str(o.error) ?? JSON.stringify(e);58}5960// ---------------------------------------------------------------- model steps61export function modelBlocks(s: Step): ContentBlock[] {62  const raw = obj(s.result).blocks;63  if (!Array.isArray(raw)) return [];64  const out: ContentBlock[] = [];65  for (const b of raw) {66    const o = obj(b);67    if (o.type === 'text' && typeof o.text === 'string') out.push({ type: 'text', text: o.text });68    else if (o.type === 'reasoning' && typeof o.summary === 'string') out.push({ type: 'reasoning', summary: o.summary });69    else if (o.type === 'tool_call' && typeof o.name === 'string') out.push({ type: 'tool_call', id: String(o.id ?? ''), name: o.name, input: o.input });70  }71  return out;72}7374// ---------------------------------------------------------------- tool steps75export interface ToolView {76  tool: string;77  input: unknown;78  /** One-line summary of the call: the path, the command, the pattern… */79  keyArg: string | null;80  output: string;81  /** null while the call is still running. */82  ok: boolean | null;83  exitCode: number | null;84  /** Shell only: what the tool reported besides the exit code (timed out, output cut, killed by a signal). */85  statusNote: string | null;86  artifact: { name: string; sha256: string; size: number | null } | null;87  diff: DiffFile[] | null;88  truncated: boolean;89}90const KEY_ARGS = ['path', 'file', 'command', 'cmd', 'pattern', 'query', 'question', 'message', 'summary', 'name', 'url', 'ref', 'branch'];91export function keyArgOf(input: unknown): string | null {92  const o = obj(input);93  const v = firstStr(o, KEY_ARGS);94  if (v != null) return v.split('\n')[0] ?? v;95  for (const val of Object.values(o)) if (typeof val === 'string' && val.length > 0) return val.split('\n')[0] ?? val;96  return null;97}98export function toolNameOf(s: Step): string {99  return str(obj(s.request).name) ?? s.name;100}101function outputOf(result: unknown): string {102  if (result == null) return '';103  if (typeof result === 'string') return result;104  const o = obj(result);105  return firstStr(o, ['content', 'output', 'text', 'stdout']) ?? JSON.stringify(result, null, 2);106}107/**108 * The `shell` tool prefixes its output with one status line written by the tool itself, e.g.109 * `[exit code 0]`, `[timed out after 120 s; the process tree was killed; exit code 143]`,110 * `[cancelled; killed by SIGTERM]`. Only that first line is trusted: a command that prints111 * "exit 0" itself must not be read as an exit code.112 */113const SHELL_STATUS_RE = /^\[([^\]\n]*(?:exit code -?\d+|killed by [^\]\n]+))\]\n?/;114export function shellStatus(output: string): { exitCode: number | null; note: string | null; body: string } {115  const m = SHELL_STATUS_RE.exec(output);116  if (!m) return { exitCode: null, note: null, body: output };117  const status = m[1] ?? '';118  const code = /exit code (-?\d+)/.exec(status);119  const rest = status.split(';').map((x) => x.trim()).filter((x) => x && !/^exit code -?\d+$/.test(x));120  return { exitCode: code ? Number(code[1]) : null, note: rest.length ? rest.join('; ') : null, body: output.slice(m[0].length) };121}122export function diffForTool(tool: string, input: unknown, result: unknown, output: string): DiffFile[] | null {123  const i = obj(input);124  const explicit = str(obj(result).diff) ?? str(i.diff);125  if (explicit && looksLikeUnifiedDiff(explicit)) return parseUnifiedDiff(explicit);126  const path = firstStr(i, ['path', 'file', 'filePath', 'file_path']);127  const succeeded = obj(result).ok !== false;128  if (tool === 'edit_file' && succeeded) {129    const before = firstStr(i, ['oldString', 'old_string', 'old', 'search', 'oldText']);130    const after = firstStr(i, ['newString', 'new_string', 'new', 'replace', 'newText']);131    if (before != null && after != null) return [fileFromHunk(path, lineDiff(before, after))];132  }133  if (tool === 'write_file' && succeeded) {134    const content = firstStr(i, ['content', 'text', 'contents']);135    if (content != null) return [fileFromHunk(path, additionsOnly(content), 'written')];136  }137  // git_diff without a `diff` field (older journals): its output is the diff. Never guessed for other tools.138  if (tool === 'git_diff' && looksLikeUnifiedDiff(output)) {139    const files = parseUnifiedDiff(output);140    if (files.length) return files;141  }142  return null;143}144export function toolView(s: Step): ToolView {145  const req = obj(s.request);146  const tool = toolNameOf(s);147  const input = 'input' in req ? req.input : s.request;148  const res = obj(s.result);149  const raw = s.status === 'failed' ? errorText(s.error) : outputOf(s.result);150  const sh = tool === 'shell' && s.status !== 'failed' ? shellStatus(raw) : { exitCode: null, note: null, body: raw };151  const ok = s.status === 'started' || s.status === 'needs_reconcile' ? null : s.status === 'completed' ? res.ok !== false : false;152  const a = obj(res.artifact);153  const artifact = typeof a.sha256 === 'string' ? { name: str(a.name) ?? a.sha256.slice(0, 12), sha256: a.sha256, size: num(a.size) } : null;154  return {155    tool, input, keyArg: keyArgOf(input), output: sh.body, ok, exitCode: sh.exitCode, statusNote: sh.note, artifact,156    diff: s.status === 'failed' ? null : diffForTool(tool, input, s.result, raw), truncated: isTruncated(s),157  };158}159160// ---------------------------------------------------------------- items161export interface PolicyDecision {162  id: number;163  seq: number | null;164  ts: string;165  decision: string;166  tool: string | null;167  reason: string | null;168  /** Id of the model's tool call this decision is about (`policy.decision.callId`). */169  callId: string | null;170  /** 'hard' | 'rule' | 'default' | 'owner' | 'expiry' */171  source: string | null;172}173export interface OwnerMessage {174  key: string;175  text: string;176  ts: string | null;177  /** Sent, not yet read by the agent (it reads steering messages between tool batches). */178  pending: boolean;179}180export interface StreamState {181  text: string;182  reasoning: string;183}184/** A tool call the model asked for that has no journal step: it was refused before running. */185export interface RefusedCall {186  id: string;187  tool: string;188  input: unknown;189  keyArg: string | null;190  /** Known only while the `policy.decision` event of this session is in memory; null after a reload. */191  reason: string | null;192}193export type Item =194  | { key: string; type: 'turn'; turn: number; step: Step | null; label: string }195  | { key: string; type: 'text'; text: string; streaming?: boolean }196  | { key: string; type: 'reasoning'; summary: string; streaming?: boolean }197  | { key: string; type: 'thinking' }198  | { key: string; type: 'tool'; step: Step }199  | { key: string; type: 'refused'; call: RefusedCall }200  | { key: string; type: 'wait'; seq: number; wait: WaitDto | null; step: Step | null }201  | { key: string; type: 'owner'; message: OwnerMessage }202  | { key: string; type: 'decision'; decision: PolicyDecision }203  | { key: string; type: 'error'; step: Step; text: string }204  | { key: string; type: 'system'; step: Step };205206const isWaitStep = (s: Step) => s.kind === 'wait';207const isOwnerStep = (s: Step) => s.kind === 'owner_messages';208/** Engine / kernel housekeeping: setup (run_meta, prefix, git_branch), the policy snapshot, clocks, timers. */209const isBookkeeping = (s: Step) => ['setup', 'policy', 'now', 'uuid', 'timer'].includes(s.kind);210211export function ownerMessagesOf(s: Step): { id: string; text: string }[] {212  const list = Array.isArray(obj(s.result).messages) ? (obj(s.result).messages as unknown[]) : [];213  return list.map((m) => obj(m)).filter((m) => typeof m.text === 'string').map((m) => ({ id: String(m.id ?? ''), text: m.text as string }));214}215216/** Payload of a journaled wait step: `request = {kind, payload, timeoutSec}`. */217export function waitPayloadOf(s: Step): Obj {218  return obj(obj(s.request).payload);219}220221/** Key-order independent JSON (Postgres jsonb reorders object keys). */222function canon(v: unknown): string {223  if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;224  if (v && typeof v === 'object') return `{${Object.keys(v as Obj).sort().map((k) => `${JSON.stringify(k)}:${canon((v as Obj)[k])}`).join(',')}}`;225  return JSON.stringify(v) ?? 'null';226}227228/**229 * Tool calls of a model turn that never ran. The engine journals a step only for a call it230 * executes (or parks on a wait); a call with invalid input or denied by policy has no step and is231 * visible only as the model's next context. So: every `tool_call` block of the turn is matched232 * against the steps journaled before the next model turn (tool steps by name + input, approval233 * waits by tool + input, question waits by their source); what is left over was not executed.234 * Only decided once the batch is over (a later model turn exists, or the run ended).235 */236export function refusedCalls(model: Step, following: readonly Step[], batchOver: boolean): Extract<ContentBlock, { type: 'tool_call' }>[] {237  if (!batchOver) return [];238  const calls = modelBlocks(model).filter((b): b is Extract<ContentBlock, { type: 'tool_call' }> => b.type === 'tool_call');239  if (calls.length === 0) return [];240  type Cand = { name: string; input: string | null; used: boolean };241  const cands: Cand[] = [];242  for (const s of following) {243    if (s.kind === 'tool_call') {244      const r = obj(s.request);245      cands.push({ name: str(r.name) ?? s.name, input: isTruncated(s) ? null : canon(r.input), used: false });246    } else if (isWaitStep(s)) {247      const p = waitPayloadOf(s);248      if (s.name === 'approval' && typeof p.tool === 'string') cands.push({ name: p.tool, input: canon(p.input), used: false });249      else if (p.source === 'ask_owner' || p.source === 'propose_criteria') cands.push({ name: p.source, input: null, used: false });250    }251  }252  const left: typeof calls = [];253  for (const c of calls) {254    const key = canon(c.input);255    // an approval wait and the tool step that follows it both stand for the same call256    const hits = cands.filter((x) => !x.used && x.name === c.name && (x.input === null || x.input === key));257    if (hits.length === 0) left.push(c);258    else for (const h of hits.filter((x) => x.input === hits[0]?.input)) h.used = true;259  }260  return left;261}262263/** Human line for a setup step worth showing (the mission branch); null = housekeeping only. */264export function setupLine(s: Step): string | null {265  if (s.kind !== 'setup' || s.name !== 'git_branch' || s.status !== 'completed') return null;266  const r = obj(s.result);267  if (r.repo !== true) return 'The workspace is not a git repository: no mission branch.';268  if (typeof r.error === 'string' && r.error) return `Could not switch to the mission branch: ${r.error}`;269  return typeof r.branch === 'string' ? `Working on branch ${r.branch}${typeof r.base === 'string' && r.base ? ` (from ${r.base})` : ''}` : null;270}271272export interface BuildInput {273  steps: readonly Step[];274  /** Waits of this run known to the client (pending from the API, resolved ones seen this session). */275  waits: readonly WaitDto[];276  decisions: readonly PolicyDecision[];277  pendingMessages: readonly OwnerMessage[];278  stream: StreamState | null;279  showSystem: boolean;280  /** The run reached a terminal status: every tool batch is over. */281  runEnded?: boolean;282}283export function buildItems(inp: BuildInput): Item[] {284  const items: Item[] = [];285  const waitBySeq = new Map<number, WaitDto>();286  for (const w of inp.waits) waitBySeq.set(w.stepSeq, w);287  const usedWaits = new Set<string>();288  const decisions = [...inp.decisions].sort((a, b) => a.id - b.id);289  const shownDecisions = new Set<number>();290  const pushDecision = (d: PolicyDecision) => {291    if (shownDecisions.has(d.id)) return;292    shownDecisions.add(d.id);293    // routine "allow" lines are noise unless asked for294    if (inp.showSystem || d.decision !== 'allow') items.push({ key: `d${d.id}`, type: 'decision', decision: d });295  };296  const steps = inp.steps;297  let openModel: Step | null = null;298  let modelCount = 0;299  for (let i = 0; i < steps.length; i++) {300    const s = steps[i] as Step;301    if (isModelStep(s)) {302      modelCount++;303      const turn = num(obj(s.request).turn) ?? modelCount;304      items.push({ key: `t${s.seq}`, type: 'turn', turn, step: s, label: `Turn ${turn}` });305      if (s.status === 'started') {306        openModel = s;307        continue;308      }309      openModel = null;310      if (s.status === 'failed') {311        items.push({ key: `e${s.seq}`, type: 'error', step: s, text: errorText(s.error) || 'The model call failed.' });312        continue;313      }314      const blocks = modelBlocks(s);315      blocks.forEach((b, n) => {316        if (b.type === 'reasoning' && b.summary.trim()) items.push({ key: `r${s.seq}.${n}`, type: 'reasoning', summary: b.summary });317        else if (b.type === 'text' && b.text.trim()) items.push({ key: `x${s.seq}.${n}`, type: 'text', text: b.text });318      });319      // what the model asked for and the engine refused (no step), then the decisions about this turn's calls320      let end = steps.length;321      for (let j = i + 1; j < steps.length; j++) if (isModelStep(steps[j] as Step)) { end = j; break; }322      const refused = refusedCalls(s, steps.slice(i + 1, end), end < steps.length || inp.runEnded === true);323      const refusedIds = new Set(refused.map((c) => c.id));324      const ids = new Set(blocks.filter((b) => b.type === 'tool_call').map((b) => (b.type === 'tool_call' ? b.id : '')));325      for (const d of decisions) {326        if (!d.callId || !ids.has(d.callId)) continue;327        if (refusedIds.has(d.callId)) shownDecisions.add(d.id); // told by the refused row itself328        else pushDecision(d);329      }330      for (const c of refused) {331        const why = [...decisions].reverse().find((d) => d.callId === c.id && d.decision === 'deny');332        items.push({ key: `n${s.seq}.${c.id}`, type: 'refused', call: { id: c.id, tool: c.name, input: c.input, keyArg: keyArgOf(c.input), reason: why?.reason ?? null } });333      }334      continue;335    }336    if (isWaitStep(s)) {337      const w = waitBySeq.get(s.seq) ?? null;338      if (w) usedWaits.add(w.id);339      items.push({ key: `w${s.seq}`, type: 'wait', seq: s.seq, wait: w, step: s });340      continue;341    }342    if (isOwnerStep(s)) {343      for (const m of ownerMessagesOf(s)) items.push({ key: `o${s.seq}.${m.id}`, type: 'owner', message: { key: m.id, text: m.text, ts: s.completedAt ?? s.startedAt, pending: false } });344      continue;345    }346    if (s.kind === 'tool_call') {347      items.push({ key: `s${s.seq}`, type: 'tool', step: s });348      continue;349    }350    if (s.status === 'failed' || s.status === 'needs_reconcile') {351      items.push({ key: `e${s.seq}`, type: 'error', step: s, text: s.status === 'needs_reconcile' ? 'This step may or may not have run before a crash; it needs your decision.' : errorText(s.error) || 'Step failed.' });352      continue;353    }354    // housekeeping is noise unless asked for (the mission branch line is always worth one row); anything unknown is shown as is355    if (inp.showSystem || !isBookkeeping(s) || setupLine(s) !== null) items.push({ key: `y${s.seq}`, type: 'system', step: s });356  }357  // decisions that could not be tied to a model turn on screen (no callId)358  for (const d of decisions) if (!d.callId) pushDecision(d);359  // pending waits that have no journal step yet360  for (const w of [...inp.waits].sort((a, b) => a.stepSeq - b.stepSeq)) {361    if (!usedWaits.has(w.id)) items.push({ key: `w${w.stepSeq}-${w.id}`, type: 'wait', seq: w.stepSeq, wait: w, step: null });362  }363  // a run that ended has nothing in flight: a step left 'started' was interrupted (cancel, crash), not running364  if (!inp.runEnded && (openModel || (inp.stream && (inp.stream.text || inp.stream.reasoning)))) {365    const k = openModel ? openModel.seq : 'live';366    if (inp.stream?.reasoning) items.push({ key: `lr${k}`, type: 'reasoning', summary: inp.stream.reasoning, streaming: true });367    if (inp.stream?.text) items.push({ key: `lx${k}`, type: 'text', text: inp.stream.text, streaming: true });368    else items.push({ key: `lt${k}`, type: 'thinking' });369  }370  const journaled = new Set(items.filter((i) => i.type === 'owner').map((i) => (i.type === 'owner' ? i.message.text : '')));371  for (const m of inp.pendingMessages) {372    if (journaled.has(m.text)) continue;373    items.push({ key: `p${m.key}`, type: 'owner', message: m });374  }375  return items;376}377378/** Stable identity of every journal-backed item: what the resume test compares. */379export function stepSeqs(steps: readonly Step[]): number[] {380  return steps.map((s) => s.seq);381}382
Files main
Menu

Theme