Repository Public
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