diff --git a/README.md b/README.md index 019a310..7c1bd33 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,8 @@ diffity inbox # run the watcher and a small status server diffity inbox --once # run a single poll-and-prepare pass, then exit diffity inbox status # print the current inbox without starting the daemon diffity inbox status --json +diffity inbox runs # every agent run of the last 7 days, and what it spent +diffity inbox runs --since 30 --json ``` On first run it writes `~/.diffity/inbox/config.json`: @@ -365,6 +367,10 @@ On first run it writes `~/.diffity/inbox/config.json`: The command itself is not configurable: the daemon builds `claude -p --output-format json` with the flags the review depends on. It runs with `--setting-sources ""`, so the agent gets none of your Claude settings — no MCP servers, no memory, no `CLAUDE.md`, none of your installed skills. Listing tools in `agent.mcpAllow` brings your MCP servers back and adds a `PreToolUse` hook (`diffity inbox mcp-gate`) that refuses every MCP call but those, by name; the prompt then tells the agent it may read the ticket or document the pull request refers to, and nothing else outside the checkout. A deny list keeps it off `gh pr review`, `gh pr comment`, `gh pr merge` and `gh api`, and off `pnpm`, `npm`, `npx`, `yarn`, `bun` and `make` — CI has already built and tested this head. The skill shipped with this build goes into the agent's system prompt — `diffity-review` for a preparation, `diffity-live` for an answer — so neither depends on what you have installed. +Every agent run is logged: the pull request and head it was for, which pass it was (`prepare` for a preparation, `answer` for a question asked in the page), the models it actually used, how long it took, its turns, its cost and its tokens, and how it ended (`prepared`, `skipped`, `answered`, `failed`, `timeout`, `rate-limited`). `diffity inbox runs` prints that log with totals — the record of what the inbox costs you. A prepared review's card carries its own share of it, "· 8 min · $1.20", with each run behind that head listed on hover, and the page's footer keeps a running total for today and for the last seven days. + +A run that ends on your Claude session limit is not the pull request's fault, so it is waited out rather than retried: the row goes back in the queue as "waiting: Claude session limit until 14:00", no failed attempt is counted against it, and no further preparation starts until the limit lifts. The reset time is read out of the agent's own message ("resets 2pm (Europe/Stockholm)"), or set half an hour ahead when the message names none. Polling and reconciling carry on meanwhile, so the page stays current and says how long the pause has left; the pause is kept with the inbox, so restarting the daemon does not spend another run rediscovering the limit. + If your config still has a `prepare` key from an earlier version, delete it — the daemon refuses to start with it, and the built command takes its place. A flag you were passing belongs in `agent.extraArgs`. > ⚠️ The agent runs inside a checkout the pull request's author controls, so it can execute their repository code. The daemon runs it without the forge's credentials in its environment, with the deny list above, and — unless you list MCP tools in `agent.mcpAllow`, which loads your settings for the servers behind the gate — with none of your Claude settings. That is defence in depth, not a sandbox. diff --git a/package-lock.json b/package-lock.json index efd4bfb..c88db16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.22", + "version": "0.10.23", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.22", + "version": "0.10.23", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.22", + "version": "0.10.23", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.22", + "version": "0.10.23", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.22", + "version": "0.10.23", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.22", + "version": "0.10.23", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index 9d34bc9..58d95b0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.22", + "version": "0.10.23", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5de6b67..cf01ff0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.22", + "version": "0.10.23", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/cli/src/commands/inbox.ts b/packages/cli/src/commands/inbox.ts index 80cc79a..1d97653 100644 --- a/packages/cli/src/commands/inbox.ts +++ b/packages/cli/src/commands/inbox.ts @@ -3,10 +3,11 @@ import pc from 'picocolors'; import { isCliInstalled, isAuthenticated } from '@diffity/github'; import { loadInboxConfig } from '../inbox/config.js'; import { inboxConfigPath, inboxStorePath } from '../inbox/paths.js'; -import { InboxStore } from '../inbox/store.js'; +import { InboxStore, type RunTotals } from '../inbox/store.js'; import { runDaemon } from '../inbox/daemon.js'; import { allowFromEnv, mcpGateDecision } from '../inbox/mcp-gate.js'; import { buildView } from '../inbox/view.js'; +import { localHhMm, localWhen, minutesOf, money, tokensLabel } from '../inbox/runs.js'; export function registerInboxCommand(program: Command): void { const inbox = program @@ -88,6 +89,7 @@ export function registerInboxCommand(program: Command): void { if (view.ready.length === 0 && view.working.length === 0 && view.other.length === 0 && view.dismissed.length === 0) { console.log(pc.dim('Nothing in the inbox yet. Run `diffity inbox` to start watching.')); + spent(view); return; } @@ -103,9 +105,71 @@ export function registerInboxCommand(program: Command): void { section('Dismissed', view.dismissed.map(row => ` ${pc.dim('dismissed')} ${row.repo}#${row.number} ${row.title}`, )); + + spent(view); + }); + + inbox + .command('runs') + .description('Print the agent runs the inbox has made, and what they spent') + .option('--json', 'Output as JSON') + .option('--since ', 'How far back to look, in days', '7') + .action((opts: { json?: boolean; since?: string }) => { + const days = Number(opts.since ?? 7); + if (!Number.isFinite(days) || days <= 0) { + console.error(pc.red('Error: --since takes a number of days.')); + process.exit(1); + } + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const store = new InboxStore(inboxStorePath()); + const runs = store.runs({ since }); + const totals = store.runTotals(since); + store.close(); + + if (opts.json) { + console.log(JSON.stringify({ since, days, runs, totals }, null, 2)); + return; + } + if (runs.length === 0) { + console.log(pc.dim(`No agent runs in the last ${days} day(s).`)); + return; + } + table([ + ['when', 'PR', 'phase', 'model', 'turns', 'min', 'cost', 'tokens', 'outcome'], + ...runs.map(run => [ + localWhen(run.startedAt), run.prId, run.phase, run.model ?? '—', + run.turns === null ? '—' : String(run.turns), + run.durationMs === null ? '—' : minutesOf([run]).toFixed(1), + money(run.costUsd), tokensLabel(run) || '—', run.outcome, + ]), + ]); + console.log(''); + console.log(`${totals.count} run${totals.count === 1 ? '' : 's'} · ${Math.round(totals.minutes)} min · ${money(totals.costUsd)} over the last ${days} day(s)`); }); } +/** What the agent has spent, and whether it is waiting out a limit, for the foot of the listing. */ +function spent(view: { runs: { today: RunTotals; week: RunTotals }; pausedUntil: string | null }): void { + const window = (totals: RunTotals) => `${totals.count} · ${Math.round(totals.minutes)} min · ${money(totals.costUsd)}`; + if (view.runs.week.count > 0) { + console.log(''); + console.log(pc.dim(`agent runs today: ${window(view.runs.today)} · 7 days: ${window(view.runs.week)}`)); + } + if (view.pausedUntil) { + console.log(pc.yellow(`Preparing paused until ${localHhMm(view.pausedUntil)} — Claude session limit`)); + } +} + +/** Rows printed as columns, the first row being the header. */ +function table(rows: string[][]): void { + const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length))); + const line = (row: string[]) => row.map((cell, column) => cell.padEnd(widths[column])).join(' ').trimEnd(); + console.log(pc.dim(line(rows[0]))); + for (const row of rows.slice(1)) { + console.log(line(row)); + } +} + /** All of stdin, parsed; unparseable input reads as null, which the gate refuses. */ async function readJsonStdin(): Promise { let raw = ''; diff --git a/packages/cli/src/inbox/agent-output.ts b/packages/cli/src/inbox/agent-output.ts index a5c8917..eb67e41 100644 --- a/packages/cli/src/inbox/agent-output.ts +++ b/packages/cli/src/inbox/agent-output.ts @@ -42,6 +42,101 @@ export function parseAgentOutput(stdout: string): { text: string; stats: RunStat }; } +/** + * Whether the run ended on the reviewer's Claude session limit, and when that limit lifts. The + * message either names a wall-clock time, sometimes with a zone — "resets 2pm (Europe/Stockholm)", + * "resets at 14:30" — read here as the next moment that clock shows it, or names how long is left + * — "resets in 90 minutes" — counted from now. `resetsAt` is null when the text names no time this + * understands, which leaves the caller to pick its own retry. + */ +export function rateLimitOf(text: string, now: Date): { resetsAt: string | null } | null { + if (!/hit your (?:session|usage) limit/i.test(text)) { + return null; + } + return { resetsAt: resetsIn(text, now) ?? resetsAt(text, now) }; +} + +/** "resets in 3 hours", "resets in 45 minutes", "resets in 1 hour 30 minutes". */ +function resetsIn(text: string, now: Date): string | null { + const match = /resets\s+in\s+(?:(\d{1,3})\s*(?:hours|hour|hrs|hr|h)\b)?\s*(?:(\d{1,3})\s*(?:minutes|minute|mins|min|m)\b)?/i.exec(text); + if (!match) { + return null; + } + const hours = match[1] ? Number(match[1]) : 0; + const minutes = match[2] ? Number(match[2]) : 0; + if (hours === 0 && minutes === 0) { + return null; + } + return new Date(now.getTime() + hours * 3_600_000 + minutes * 60_000).toISOString(); +} + +function resetsAt(text: string, now: Date): string | null { + const match = /resets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(\s*([A-Za-z0-9_+\-/]+)\s*\))?/i.exec(text); + if (!match) { + return null; + } + const [, rawHour, rawMinute, meridiem, zone] = match; + const minute = rawMinute ? Number(rawMinute) : 0; + let hour = Number(rawHour); + if (meridiem) { + if (hour < 1 || hour > 12) { + return null; + } + hour = (hour % 12) + (meridiem.toLowerCase() === 'pm' ? 12 : 0); + } + if (hour > 23 || minute > 59) { + return null; + } + // Today if that time is still ahead in the zone the message named, tomorrow otherwise. + for (const dayOffset of [0, 1]) { + const at = wallClockInstant(now, zone ?? null, hour, minute, dayOffset); + if (at > now.getTime()) { + return new Date(at).toISOString(); + } + } + return null; +} + +/** The instant at which a zone's clock reads this hour and minute, `dayOffset` days from now. */ +function wallClockInstant(now: Date, zone: string | null, hour: number, minute: number, dayOffset: number): number { + if (zone) { + try { + const [year, month, day] = zonedDate(zone, now); + const wanted = Date.UTC(year, month - 1, day + dayOffset, hour, minute); + // The offset is read at the guessed instant and then at the corrected one, so a reset that + // falls on a daylight-saving change still lands on the clock time the message named. + const once = wanted - zoneOffsetMs(zone, new Date(wanted)); + return wanted - zoneOffsetMs(zone, new Date(once)); + } catch { + // Not a zone Intl knows (an abbreviation, say): the reviewer's own clock is the better guess. + } + } + return new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, hour, minute).getTime(); +} + +function zonedDate(zone: string, at: Date): [number, number, number] { + const parts = zoneParts(zone, at); + return [parts.year, parts.month, parts.day]; +} + +/** How far ahead of UTC the zone's clock is at that instant. */ +function zoneOffsetMs(zone: string, at: Date): number { + const { year, month, day, hour, minute, second } = zoneParts(zone, at); + return Date.UTC(year, month - 1, day, hour, minute, second) - at.getTime(); +} + +function zoneParts(zone: string, at: Date): { year: number; month: number; day: number; hour: number; minute: number; second: number } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(at); + const value = (type: Intl.DateTimeFormatPartTypes): number => Number(parts.find(part => part.type === type)?.value); + return { + year: value('year'), month: value('month'), day: value('day'), + hour: value('hour') % 24, minute: value('minute'), second: value('second'), + }; +} + function resultObject(stdout: string): Record | null { let parsed: unknown; try { diff --git a/packages/cli/src/inbox/attendant.ts b/packages/cli/src/inbox/attendant.ts index 95bdf0b..1b7e620 100644 --- a/packages/cli/src/inbox/attendant.ts +++ b/packages/cli/src/inbox/attendant.ts @@ -13,13 +13,15 @@ export interface AttendedPr { url: string; title: string; author: string; + /** The head the prepared review is for, so an answer's run is logged against it. */ + headSha: string | null; } export interface AttendantDeps { /** Parks on the session once — one `agent await` — and says how it ended. Aborting ends it early. */ awaitRequest(worktree: string, signal: AbortSignal): Promise; /** Runs the answering agent for one request; resolves when it has finished, saying if it was cut short. */ - answer(worktree: string, prompt: string, signal: AbortSignal): Promise<{ timedOut: boolean }>; + answer(worktree: string, pr: AttendedPr, prompt: string, signal: AbortSignal): Promise<{ timedOut: boolean }>; /** Closes a request the agent could not answer, with a note in the thread, so it is not asked again. */ giveUp(worktree: string, request: LiveRequest, note: string): Promise; log(message: string): void; @@ -93,7 +95,7 @@ export class Attendants { this.deps.log(`${pr.id}: the reader asked about ${request.filePath}:${request.startLine}`); // Not awaited: the wait is re-armed at once, and a second question arriving meanwhile // queues behind this one on the server rather than finding nobody parked. - void this.deps.answer(worktree, composeLivePrompt(pr, worktree, request), signal) + void this.deps.answer(worktree, pr, composeLivePrompt(pr, worktree, request), signal) .then(({ timedOut }) => timedOut ? this.deps.giveUp(worktree, request, 'The agent did not finish answering within the time allowed.') : undefined) diff --git a/packages/cli/src/inbox/daemon.ts b/packages/cli/src/inbox/daemon.ts index 1740d70..832fe37 100644 --- a/packages/cli/src/inbox/daemon.ts +++ b/packages/cli/src/inbox/daemon.ts @@ -9,6 +9,7 @@ import { logsDir, preparePr, type PrepareDeps } from './prepare.js'; import { realAttendantDeps, realPrepareDeps, type Inflight } from './runtime.js'; import { Attendants, type AttendedPr } from './attendant.js'; import { removeWorktree, cloneDir } from './worktree.js'; +import { localHhMm } from './runs.js'; import { findInstanceForRepo, killInstance } from '../registry.js'; import { repoHash } from './open-session.js'; import { InboxStore } from './store.js'; @@ -61,6 +62,8 @@ export interface ServerHooks { export interface DaemonStatus { ticking: boolean; lastPollAt: string | null; + /** Until when preparation is held back by the reviewer's Claude limit, or null when it is not. */ + pausedUntil: string | null; } /** The settings as the page reads and writes them: the running config, persisted when a path is known. */ @@ -116,6 +119,9 @@ export async function runDaemon( const inflight: Inflight = {}; const prepareDeps: PrepareDeps = realPrepareDeps(nodePath, entry, inboxDataDir, config, log, inflight); + // The pause outlives this process: a session limit is the reviewer's, not the daemon's, so it is + // kept in the store and a restart does not spend a run rediscovering it. + const pausedUntil = () => store.pausedUntil(new Date().toISOString()); const deps = { forge: options.forge ?? realForge, prepare: (snapshot: Parameters[0], opts: { bumped: boolean }) => preparePr(snapshot, config, prepareDeps, opts), @@ -125,6 +131,12 @@ export async function runDaemon( shouldContinue: () => !stopping, // Read at each tick, not copied: the page can change it while the daemon runs. get maxPrepared() { return config.maxPrepared; }, + get agentModel() { return config.agent.model; }, + pauseUntil: (until: string) => { + store.pauseUntil(until); + log(`preparing paused until ${localHhMm(until)} — Claude session limit`); + }, + pausedUntil, }; // A bump arriving mid-tick is served by another tick right after, not by the next poll. @@ -164,7 +176,7 @@ export async function runDaemon( // server's error handler) before it can reclaim and kill the first one's in-flight servers. const openDeps = options.openDeps ?? realOpenSessionDeps(nodePath, entry); const attendants: AttendantHost = options.attendants ?? new Attendants( - realAttendantDeps(nodePath, entry, config, worktree => join(logsDir(), `${basename(worktree)}.live.log`), log), + realAttendantDeps(nodePath, entry, config, worktree => join(logsDir(), `${basename(worktree)}.live.log`), log, run => store.recordRun(run)), ); let timer: NodeJS.Timeout | undefined; const armPoll = () => { @@ -175,7 +187,7 @@ export async function runDaemon( }; const settings = settingsHost(config, options.configPath, armPoll); const server = await bindInboxServer(store, config, log, openDeps, { - attendants, onBump: requestTick, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt }), + attendants, onBump: requestTick, onTick: requestTick, settings, status: () => ({ ticking, lastPollAt, pausedUntil: pausedUntil() }), }); reclaimLeftoverServers(log); armPoll(); @@ -363,7 +375,7 @@ async function handleOpen(store: InboxStore, config: InboxConfig, id: string, op } const { pr } = resolution; if (config.live) { - attendants?.ensure(pr.worktreePath!, { id: pr.id, url: pr.url, title: pr.title, author: pr.author }); + attendants?.ensure(pr.worktreePath!, { id: pr.id, url: pr.url, title: pr.title, author: pr.author, headSha: pr.preparedHeadSha ?? pr.headSha }); } res.writeHead(302, { Location: url }); res.end(); diff --git a/packages/cli/src/inbox/page.ts b/packages/cli/src/inbox/page.ts index a4667b7..cb4138e 100644 --- a/packages/cli/src/inbox/page.ts +++ b/packages/cli/src/inbox/page.ts @@ -71,7 +71,7 @@ export function inboxPage(): string { .title { flex: 1; min-width: 0; } .title .name { font-weight: 600; } .title .repo { color: var(--muted); font-weight: 500; } - .title .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .title .meta { color: var(--muted); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; } .badge.stale { color: var(--stale); border: 1px solid var(--stale); } .badge.work { color: var(--work); border: 1px solid var(--line); } @@ -167,6 +167,26 @@ export function inboxPage(): string { return text ? '
' + text + '
' : ''; } + function money(usd) { return usd == null ? '\\u2014' : '$' + usd.toFixed(2); } + + function hhmm(iso) { + const at = new Date(iso); + return String(at.getHours()).padStart(2, '0') + ':' + String(at.getMinutes()).padStart(2, '0'); + } + + /** What the agent runs behind a prepared review came to; the hover has them one by one. */ + function spendLabel(r) { + return r.spend ? Math.round(r.spend.minutes) + ' min \\u00b7 ' + money(r.spend.costUsd) : ''; + } + + function totals(t) { + return t.count + ' \\u00b7 ' + Math.round(t.minutes) + ' min \\u00b7 ' + money(t.costUsd); + } + + function runsLabel(runs) { + return runs ? 'agent runs today: ' + totals(runs.today) + ' \\u00b7 7 days: ' + totals(runs.week) : ''; + } + function times(r) { const parts = []; if (r.createdAt) parts.push('opened ' + ago(r.createdAt)); @@ -185,7 +205,8 @@ export function inboxPage(): string { '' + sizeLabel(r) + '' + '
' + esc(r.repo) + '#' + r.number + ' ' + '' + esc(r.title) + '
' + - '
by ' + esc(r.author) + ' \\u00b7 ' + r.changedFiles + ' file(s)' + (r.summary ? ' \\u00b7 ' + esc(r.summary) : '') + (times(r) ? ' \\u00b7 ' + times(r) : '') + '
' + + metaLine(['by ' + esc(r.author), r.changedFiles + ' file(s)', esc(r.summary || ''), spendLabel(r), times(r)], + r.spend ? r.spend.detail : '') + '' + (r.alert ? 'alert' : '') + (r.stale ? 'stale' : '') + 'open \\u2197'; @@ -373,7 +394,11 @@ export function inboxPage(): string { el('all-empty').hidden = total > 0; el('status').textContent = view.ready.length + ' ready \\u00b7 ' + view.working.length + ' queued'; showReload(view.ticking === true); - el('foot').textContent = 'Updated ' + new Date().toLocaleTimeString() + (view.lastPollAt ? ' \\u00b7 last poll ' + ago(view.lastPollAt) : ''); + el('foot').textContent = [ + 'Updated ' + new Date().toLocaleTimeString() + (view.lastPollAt ? ' \\u00b7 last poll ' + ago(view.lastPollAt) : ''), + runsLabel(view.runs), + view.pausedUntil ? 'preparing paused until ' + hhmm(view.pausedUntil) + ' \\u2014 Claude session limit' : '', + ].filter(Boolean).join(' \\u00b7 '); } catch (err) { el('status').textContent = 'the inbox daemon is not responding'; } diff --git a/packages/cli/src/inbox/prepare.ts b/packages/cli/src/inbox/prepare.ts index 24e11fb..e8339a2 100644 --- a/packages/cli/src/inbox/prepare.ts +++ b/packages/cli/src/inbox/prepare.ts @@ -1,8 +1,9 @@ import { join } from 'node:path'; import type { PrSnapshot } from '@diffity/github'; import type { InboxConfig } from './config.js'; -import { parseAgentOutput, type RunStats } from './agent-output.js'; +import { parseAgentOutput, rateLimitOf, type RunStats } from './agent-output.js'; import { inboxDir } from './paths.js'; +import { localHhMm } from './runs.js'; import { composePrompt, verdictOf } from './prompt.js'; import { summarizeBundleFile } from './summary.js'; import { cloneDir, prepareWorktree, removeWorktree, worktreePath } from './worktree.js'; @@ -39,10 +40,23 @@ export interface PrepareDeps { now(): string; } +/** + * What a failed preparation failed at. `worktree` is the one that happened before any agent ran; + * `rate-limit` is the one that is not the pull request's fault and is waited out rather than retried. + */ +export type PrepareFailure = 'worktree' | 'timeout' | 'budget' | 'rate-limit' | 'agent' | 'bundle'; + +/** When the agent ran and what it reported, on every outcome, so the run log has it either way. */ +export interface RunLog { + startedAt: string; + endedAt: string; + stats: RunStats | null; +} + export type PrepareResult = - | { kind: 'prepared'; headSha: string; bundlePath: string; worktree: string; logPath: string; at: string; summary: string | null; alert: string | null; stats: RunStats | null } - | { kind: 'skipped'; reason: string; logPath: string; stats: RunStats | null } - | { kind: 'failed'; reason: string; worktree: string | null; logPath: string | null; stats: RunStats | null }; + | { kind: 'prepared'; headSha: string; bundlePath: string; worktree: string; logPath: string; at: string; summary: string | null; alert: string | null; run: RunLog } + | { kind: 'skipped'; reason: string; logPath: string; run: RunLog } + | { kind: 'failed'; reason: string; failure: PrepareFailure; worktree: string | null; logPath: string | null; run: RunLog; resetsAt?: string | null }; /** Where the daemon keeps what preparation produces, beside its config rather than the worktrees. */ export function bundlesDir(): string { @@ -69,18 +83,21 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps: const clone = cloneDir(config.reposDir, snapshot.repo); const logPath = join(logsDir(), `${snapshot.owner}-${snapshot.repo}-${snapshot.number}.log`); + const before = deps.now(); + let run: RunLog = { startedAt: before, endedAt: before, stats: null }; + let head: string; let diffRef: string; try { ({ head, diffRef } = await prepareWorktree(clone, dest, snapshot, snapshot.baseRef)); } catch (err) { - return { kind: 'failed', reason: err instanceof Error ? err.message : String(err), worktree: null, logPath: null, stats: null }; + return { kind: 'failed', failure: 'worktree', reason: err instanceof Error ? err.message : String(err), worktree: null, logPath: null, run }; } let server: ServerHandle | null = null; - let stats: RunStats | null = null; try { server = await deps.startServer(dest, diffRef); + const startedAt = deps.now(); const { stdout, timedOut } = await deps.runAgent({ argv: deps.agentArgv(), prompt: composePrompt({ @@ -92,30 +109,45 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps: timeoutMs: config.prepareTimeoutMinutes * 60_000, }); + run = { startedAt, endedAt: deps.now(), stats: null }; + if (timedOut) { await removeWorktree(clone, dest); - return { kind: 'failed', reason: `the agent did not finish within ${config.prepareTimeoutMinutes} minutes`, worktree: null, logPath, stats: null }; + return { kind: 'failed', failure: 'timeout', reason: `the agent did not finish within ${config.prepareTimeoutMinutes} minutes`, worktree: null, logPath, run }; } const parsed = parseAgentOutput(stdout); - stats = parsed.stats; + run = { ...run, stats: parsed.stats }; - if (stats?.subtype === 'error_max_budget_usd') { + if (parsed.stats?.subtype === 'error_max_budget_usd') { await removeWorktree(clone, dest); // The cap can come from agent.extraArgs rather than agent.maxBudgetUsd, in which case the // daemon does not know the number the agent hit. const budget = config.agent.maxBudgetUsd; - return { kind: 'failed', reason: budget === null ? 'the agent hit its budget' : `the agent hit its budget of $${budget}`, worktree: null, logPath, stats }; + return { kind: 'failed', failure: 'budget', reason: budget === null ? 'the agent hit its budget' : `the agent hit its budget of $${budget}`, worktree: null, logPath, run }; + } + + // Nothing was reviewed and nothing is wrong with the pull request: the daemon waits the limit + // out rather than spending the pull request's retries on it. + const limit = rateLimitOf(parsed.text, new Date(run.endedAt)); + if (limit) { + await removeWorktree(clone, dest); + return { + kind: 'failed', failure: 'rate-limit', worktree: null, logPath, run, resetsAt: limit.resetsAt, + reason: limit.resetsAt + ? `waiting: Claude session limit until ${localHhMm(limit.resetsAt)}` + : 'waiting: Claude session limit, retrying in 30 minutes', + }; } const verdict = verdictOf(parsed.text); if (verdict.kind === 'skipped') { await removeWorktree(clone, dest); - return { kind: 'skipped', reason: verdict.reason, logPath, stats }; + return { kind: 'skipped', reason: verdict.reason, logPath, run }; } if (verdict.kind === 'none') { await removeWorktree(clone, dest); - return { kind: 'failed', reason: 'the agent ended without SKIP or PREPARED', worktree: null, logPath, stats }; + return { kind: 'failed', failure: 'agent', reason: 'the agent ended without SKIP or PREPARED', worktree: null, logPath, run }; } // The head actually checked out, which may be newer than the snapshot if the author pushed @@ -124,12 +156,12 @@ export async function preparePr(snapshot: PrSnapshot, config: InboxConfig, deps: try { await deps.exportBundle({ worktree: dest, prNumber: snapshot.number, outPath: bundlePath }); } catch (err) { - return { kind: 'failed', reason: `the review was prepared but its bundle could not be written: ${err instanceof Error ? err.message : err}`, worktree: dest, logPath, stats }; + return { kind: 'failed', failure: 'bundle', reason: `the review was prepared but its bundle could not be written: ${err instanceof Error ? err.message : err}`, worktree: dest, logPath, run }; } - return { kind: 'prepared', headSha: head, bundlePath, worktree: dest, logPath, at: deps.now(), summary: summarizeBundleFile(bundlePath), alert: verdict.alert, stats }; + return { kind: 'prepared', headSha: head, bundlePath, worktree: dest, logPath, at: deps.now(), summary: summarizeBundleFile(bundlePath), alert: verdict.alert, run }; } catch (err) { - return { kind: 'failed', reason: err instanceof Error ? err.message : String(err), worktree: dest, logPath, stats }; + return { kind: 'failed', failure: 'agent', reason: err instanceof Error ? err.message : String(err), worktree: dest, logPath, run }; } finally { server?.stop(); } diff --git a/packages/cli/src/inbox/runs.ts b/packages/cli/src/inbox/runs.ts new file mode 100644 index 0000000..7f1e9f9 --- /dev/null +++ b/packages/cli/src/inbox/runs.ts @@ -0,0 +1,68 @@ +import type { RunRow } from './store.js'; + +/** A token count as the log shows it: the magnitude is what the reviewer reads, not the digits. */ +export function formatTokens(count: number): string { + if (count < 1000) { + return String(count); + } + if (count < 1_000_000) { + return `${Math.round(count / 1000)}k`; + } + return `${(count / 1_000_000).toFixed(1)}M`; +} + +/** The two counts worth watching on a review run: what it wrote, and what it re-read. */ +export function tokensLabel(run: { outputTokens: number | null; cacheReadTokens: number | null }): string { + const parts: string[] = []; + if (run.outputTokens !== null) { + parts.push(`out ${formatTokens(run.outputTokens)}`); + } + if (run.cacheReadTokens !== null) { + parts.push(`read ${formatTokens(run.cacheReadTokens)}`); + } + return parts.join(' · '); +} + +export function minutesOf(runs: { durationMs: number | null }[]): number { + const ms = runs.reduce((total, run) => total + (run.durationMs ?? 0), 0); + return Math.round(ms / 6000) / 10; +} + +/** What the runs cost, or null when not one of them reported a cost. */ +export function costOf(runs: { costUsd: number | null }[]): number | null { + const known = runs.filter(run => run.costUsd !== null); + return known.length === 0 ? null : known.reduce((total, run) => total + (run.costUsd ?? 0), 0); +} + +/** One run on one line, for the hover over a prepared review's cost. */ +export function runDetail(run: RunRow): string { + const parts = [run.phase, run.model ?? 'unknown model']; + if (run.turns !== null) { + parts.push(`${run.turns} turns`); + } + const tokens = tokensLabel(run); + if (tokens) { + parts.push(tokens); + } + return parts.join(' · '); +} + +export function money(usd: number | null): string { + return usd === null ? '—' : `$${usd.toFixed(2)}`; +} + +/** A moment on the reviewer's own clock, hours and minutes. */ +export function localHhMm(iso: string): string { + const at = new Date(iso); + return `${pad(at.getHours())}:${pad(at.getMinutes())}`; +} + +/** A moment on the reviewer's own clock, as the run log's first column. */ +export function localWhen(iso: string): string { + const at = new Date(iso); + return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${localHhMm(iso)}`; +} + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} diff --git a/packages/cli/src/inbox/runtime.ts b/packages/cli/src/inbox/runtime.ts index 82a3abe..f1ade1c 100644 --- a/packages/cli/src/inbox/runtime.ts +++ b/packages/cli/src/inbox/runtime.ts @@ -8,6 +8,7 @@ import type { InboxConfig } from './config.js'; import { buildAgentArgv, skillBody } from './agent-argv.js'; import { parseAgentOutput } from './agent-output.js'; import { parseAwaitOutcome, type AttendantDeps } from './attendant.js'; +import { runRecordOf, type RunRecord } from './store.js'; import { diffityDir } from '../registry.js'; /** @@ -214,9 +215,17 @@ function agentEnv(dataDir: string, mcpAllow: string[]): NodeJS.ProcessEnv { * The real side effects behind an attendant. The wait is this CLI's own `agent await` over the * worktree; the answer is the same built agent command as a preparation, with the forge's * credentials stripped, but in the reviewer's own diffity data directory — the opened session lives - * there, and the reply has to land in it. + * there, and the reply has to land in it. Every answer is an agent run, so `recordRun` puts it in + * the same log as the preparations. */ -export function realAttendantDeps(nodePath: string, entry: string, config: InboxConfig, logPathFor: (worktree: string) => string, log: (message: string) => void): AttendantDeps { +export function realAttendantDeps( + nodePath: string, + entry: string, + config: InboxConfig, + logPathFor: (worktree: string) => string, + log: (message: string) => void, + recordRun: (run: RunRecord) => void = () => {}, +): AttendantDeps { return { awaitRequest: (worktree, signal) => new Promise(resolve => { const child = spawn(nodePath, [entry, '--repo', worktree, 'agent', 'await', '--timeout', '240'], { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -231,12 +240,13 @@ export function realAttendantDeps(nodePath: string, entry: string, config: Inbox child.on('error', err => { signal.removeEventListener('abort', onAbort); resolve({ kind: 'failed', reason: err.message }); }); child.on('close', code => { signal.removeEventListener('abort', onAbort); resolve(parseAwaitOutcome(code, stdout, stderr)); }); }), - answer: async (worktree, prompt, signal) => { + answer: async (worktree, pr, prompt, signal) => { const inflight: Inflight = {}; const onAbort = () => inflight.agentKill?.(); signal.addEventListener('abort', onAbort, { once: true }); + const startedAt = new Date().toISOString(); try { - const { timedOut } = await runAgent({ + const { stdout, timedOut } = await runAgent({ // The live skill, not the review one: the live prompt tells the agent to follow it, and // the agent runs with none of the reviewer's installed skills to find it in. argv: buildAgentArgv({ nodePath, entry, agent: config.agent, systemPrompt: skillBody(entry, 'diffity-live', log) }), @@ -246,6 +256,16 @@ export function realAttendantDeps(nodePath: string, entry: string, config: Inbox if (timedOut) { log(`the answering agent in ${worktree} did not finish within ${config.liveTimeoutMinutes} minutes`); } + const { stats } = parseAgentOutput(stdout); + // Killing the agent through the signal — the daemon stopping, the reader leaving — closes + // the child like a clean exit, so only the signal itself tells that apart from an answer. + const stopped = signal.aborted; + recordRun(runRecordOf({ + prId: pr.id, headSha: pr.headSha, phase: 'answer', + outcome: stopped || stats?.isError ? 'failed' : timedOut ? 'timeout' : 'answered', + note: stopped ? 'stopped before it answered' : null, + startedAt, endedAt: new Date().toISOString(), stats, configModel: config.agent.model, + })); return { timedOut }; } finally { signal.removeEventListener('abort', onAbort); diff --git a/packages/cli/src/inbox/store.ts b/packages/cli/src/inbox/store.ts index cfd7396..2d00a21 100644 --- a/packages/cli/src/inbox/store.ts +++ b/packages/cli/src/inbox/store.ts @@ -2,6 +2,7 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import type { PrSnapshot } from '@diffity/github'; +import type { RunStats } from './agent-output.js'; export const INBOX_STATUSES = [ 'queued', @@ -68,6 +69,89 @@ export interface Prepared { alert: string | null; } +/** Which agent pass a run was: the drafting one, the one that checks its findings, or an answer. */ +export const RUN_PHASES = ['prepare', 'validate', 'answer'] as const; +export type RunPhase = (typeof RUN_PHASES)[number]; + +export const RUN_OUTCOMES = ['prepared', 'skipped', 'validated', 'answered', 'failed', 'timeout', 'rate-limited'] as const; +export type RunOutcome = (typeof RUN_OUTCOMES)[number]; + +/** One agent run as the log keeps it: what it was for, what it spent, and how it ended. */ +export interface RunRecord { + prId: string; + /** The head the run was about; null when the run was not tied to one. */ + headSha: string | null; + phase: RunPhase; + /** The models the run actually used, comma-separated, or the one the config asked for. */ + model: string | null; + startedAt: string; + endedAt: string; + durationMs: number | null; + turns: number | null; + costUsd: number | null; + inputTokens: number | null; + outputTokens: number | null; + cacheReadTokens: number | null; + cacheWriteTokens: number | null; + outcome: RunOutcome; + /** Why it ended as it did, when there is more to say than the outcome. */ + note: string | null; +} + +export interface RunRow extends RunRecord { + id: number; +} + +export interface RunTotals { + count: number; + minutes: number; + costUsd: number; +} + +/** + * A run as the log wants it, from what the agent reported. `--output-format json` gives the run's + * own duration; without it the wall clock the daemon measured stands in, and the model is the one + * the config asked for rather than the one that answered. + */ +export function runRecordOf(input: { + prId: string; + headSha: string | null; + phase: RunPhase; + outcome: RunOutcome; + startedAt: string; + endedAt: string; + stats: RunStats | null; + /** `agent.model`, used when the run did not say which models it spent on. */ + configModel: string | null; + note?: string | null; +}): RunRecord { + const { stats } = input; + const models = stats?.models.length ? stats.models.join(',') : null; + return { + prId: input.prId, + headSha: input.headSha, + phase: input.phase, + model: models ?? input.configModel ?? null, + startedAt: input.startedAt, + endedAt: input.endedAt, + durationMs: stats?.durationMs ?? elapsedMs(input.startedAt, input.endedAt), + turns: stats?.turns ?? null, + costUsd: stats?.costUsd ?? null, + inputTokens: stats?.inputTokens ?? null, + outputTokens: stats?.outputTokens ?? null, + cacheReadTokens: stats?.cacheReadTokens ?? null, + cacheWriteTokens: stats?.cacheWriteTokens ?? null, + outcome: input.outcome, + note: input.note ?? null, + }; +} + +function elapsedMs(startedAt: string, endedAt: string): number | null { + const from = Date.parse(startedAt); + const to = Date.parse(endedAt); + return Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, to - from) : null; +} + export function prId(ref: { owner: string; repo: string; number: number }): string { return `${ref.owner}/${ref.repo}#${ref.number}`; } @@ -126,6 +210,28 @@ export class InboxStore { alert TEXT ) `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS inbox_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pr_id TEXT NOT NULL, + head_sha TEXT, + phase TEXT NOT NULL, + model TEXT, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + duration_ms INTEGER, + turns INTEGER, + cost_usd REAL, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + outcome TEXT NOT NULL, + note TEXT + ) + `); + this.db.exec('CREATE INDEX IF NOT EXISTS inbox_runs_pr_started ON inbox_runs (pr_id, started_at)'); + this.db.exec('CREATE TABLE IF NOT EXISTS inbox_state (key TEXT PRIMARY KEY, value TEXT)'); // A table from an earlier build gains the columns it lacks; a fresh one already has them. for (const column of ['attempts INTEGER NOT NULL DEFAULT 0', 'created_at TEXT', 'updated_at TEXT', 'bumped_at TEXT', 'summary TEXT', 'alert TEXT']) { try { @@ -218,6 +324,64 @@ export class InboxStore { `).run(prepared.headSha, prepared.at, prepared.bundlePath, prepared.worktreePath, prepared.logPath, prepared.summary, prepared.alert, id); } + recordRun(run: RunRecord): void { + this.db.prepare(` + INSERT INTO inbox_runs ( + pr_id, head_sha, phase, model, started_at, ended_at, duration_ms, turns, cost_usd, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, outcome, note + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + run.prId, run.headSha, run.phase, run.model, run.startedAt, run.endedAt, run.durationMs, + run.turns, run.costUsd, run.inputTokens, run.outputTokens, run.cacheReadTokens, + run.cacheWriteTokens, run.outcome, run.note, + ); + } + + /** The run log, newest first. */ + runs(opts: { since?: string; prId?: string } = {}): RunRow[] { + const where: string[] = []; + const params: string[] = []; + if (opts.since) { + where.push('started_at >= ?'); + params.push(opts.since); + } + if (opts.prId) { + where.push('pr_id = ?'); + params.push(opts.prId); + } + const sql = `SELECT * FROM inbox_runs${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY started_at DESC, id DESC`; + return (this.db.prepare(sql).all(...params) as unknown as RunDbRow[]).map(rowToRun); + } + + /** What the agent has spent since a moment; a run whose cost went unreported adds nothing to it. */ + runTotals(since: string): RunTotals { + const row = this.db.prepare( + 'SELECT COUNT(*) AS count, SUM(duration_ms) AS ms, SUM(cost_usd) AS cost FROM inbox_runs WHERE started_at >= ?', + ).get(since) as unknown as { count: number; ms: number | null; cost: number | null }; + return { count: row.count, minutes: round1((row.ms ?? 0) / 60_000), costUsd: row.cost ?? 0 }; + } + + /** The runs behind one prepared review — every pass made at that head, oldest first. */ + latestRunsFor(prId: string, headSha: string): RunRow[] { + return (this.db.prepare('SELECT * FROM inbox_runs WHERE pr_id = ? AND head_sha = ? ORDER BY started_at ASC, id ASC') + .all(prId, headSha) as unknown as RunDbRow[]).map(rowToRun); + } + + /** Holds preparation back until a moment, or lifts the hold when given null. */ + pauseUntil(until: string | null): void { + if (until === null) { + this.db.prepare("DELETE FROM inbox_state WHERE key = 'pausedUntil'").run(); + return; + } + this.db.prepare("INSERT INTO inbox_state (key, value) VALUES ('pausedUntil', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(until); + } + + /** Until when preparation is held back, or null when it is not — a moment already past is not one. */ + pausedUntil(now: string): string | null { + const row = this.db.prepare("SELECT value FROM inbox_state WHERE key = 'pausedUntil'").get() as unknown as { value: string } | undefined; + return row && row.value > now ? row.value : null; + } + /** Where the preparation left its trail, kept even when it ended in a skip or a failure. */ setPaths(id: string, paths: { worktreePath?: string | null; logPath?: string | null }): void { if (paths.worktreePath !== undefined) { @@ -295,6 +459,50 @@ function rowToPr(row: Row): InboxPr { }; } +interface RunDbRow { + id: number; + pr_id: string; + head_sha: string | null; + phase: string; + model: string | null; + started_at: string; + ended_at: string; + duration_ms: number | null; + turns: number | null; + cost_usd: number | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + outcome: string; + note: string | null; +} + +function rowToRun(row: RunDbRow): RunRow { + return { + id: row.id, + prId: row.pr_id, + headSha: row.head_sha, + phase: row.phase as RunPhase, + model: row.model, + startedAt: row.started_at, + endedAt: row.ended_at, + durationMs: row.duration_ms, + turns: row.turns, + costUsd: row.cost_usd, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + cacheReadTokens: row.cache_read_tokens, + cacheWriteTokens: row.cache_write_tokens, + outcome: row.outcome as RunOutcome, + note: row.note, + }; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + /** A row from a build that knew other statuses is shown as needing work rather than crashing the list. */ function normaliseStatus(value: string): InboxStatus { return (INBOX_STATUSES as readonly string[]).includes(value) ? (value as InboxStatus) : 'queued'; diff --git a/packages/cli/src/inbox/tick.ts b/packages/cli/src/inbox/tick.ts index 8e434b8..9e2049d 100644 --- a/packages/cli/src/inbox/tick.ts +++ b/packages/cli/src/inbox/tick.ts @@ -1,6 +1,7 @@ import type { PrRef, PrSnapshot } from '@diffity/github'; import { reconcile } from './reconcile.js'; -import { isRetired, prId, type InboxPr, type InboxStore } from './store.js'; +import { isRetired, prId, runRecordOf, type InboxPr, type InboxStore, type RunOutcome } from './store.js'; +import { localHhMm } from './runs.js'; import type { PrepareResult } from './prepare.js'; /** The forge, as one tick needs it — one interface so a test can stand in for GitHub. */ @@ -21,6 +22,12 @@ export interface TickDeps { shouldContinue?(): boolean; /** How many prepared reviews may wait for the reviewer at once; the rest of the queue waits. */ maxPrepared: number; + /** `agent.model`, recorded for a run that did not report which models it spent on. */ + agentModel: string | null; + /** Holds preparation back until then — a session limit is waited out, not retried. */ + pauseUntil(until: string): void; + /** Until when preparation is held back, or null when it is not; polling carries on regardless. */ + pausedUntil?(): string | null; } /** @@ -73,6 +80,21 @@ export async function runTick(store: InboxStore, deps: TickDeps): Promise } } + // A pause is the reviewer's Claude limit, not the forge's: the poll above still ran, so the page + // is current, and only the agent runs wait. The reason goes back on every row held back, because + // the reconcile above has just cleared it — a queued row reads as plainly queued otherwise. + const pausedUntil = deps.pausedUntil?.() ?? null; + if (pausedUntil) { + const reason = `waiting: preparing paused until ${localHhMm(pausedUntil)}`; + for (const snapshot of toPrepare) { + store.setStatus(prId(snapshot), 'queued', reason); + } + if (toPrepare.length > 0) { + deps.log(`${toPrepare.length} left queued: ${reason}`); + } + return; + } + // Bumped ones first, in the order they were asked for, and past the cap: the reviewer wants them // now. A stale review is already in the reviewer's pile and is only refreshed. New ones fill the // pile smallest first and no further than `maxPrepared`: each preparation spends an agent run, so @@ -126,6 +148,7 @@ async function prepareOne(store: InboxStore, snapshot: PrSnapshot, deps: TickDep const result = await deps.prepare(snapshot, { bumped }); store.clearBump(id); + recordPrepareRun(store, snapshot, deps, result); switch (result.kind) { case 'prepared': store.markPrepared(id, { @@ -145,6 +168,15 @@ async function prepareOne(store: InboxStore, snapshot: PrSnapshot, deps: TickDep deps.log(`skipped ${id}: ${result.reason}`); return; case 'failed': + if (result.failure === 'rate-limit') { + // Nothing is wrong with this pull request, so it keeps its retries and goes back in the + // queue; nothing else is prepared until the limit lifts either. + store.setStatus(id, 'queued', result.reason); + store.setPaths(id, { worktreePath: null, logPath: result.logPath ?? null }); + deps.pauseUntil(result.resetsAt ?? new Date(Date.parse(deps.now()) + 30 * 60_000).toISOString()); + deps.log(`${id}: ${result.reason}`); + return; + } store.failAttempt(id, result.reason); store.setPaths(id, { worktreePath: result.worktree ?? null, logPath: result.logPath ?? null }); deps.log(`failed to prepare ${id}: ${result.reason}`); @@ -152,6 +184,37 @@ async function prepareOne(store: InboxStore, snapshot: PrSnapshot, deps: TickDep } } +/** The preparation's agent run in the log — unless it never got as far as running one. */ +function recordPrepareRun(store: InboxStore, snapshot: PrSnapshot, deps: TickDeps, result: PrepareResult): void { + if (result.kind === 'failed' && result.failure === 'worktree') { + return; + } + store.recordRun(runRecordOf({ + prId: prId(snapshot), + headSha: result.kind === 'prepared' ? result.headSha : snapshot.headSha, + phase: 'prepare', + outcome: outcomeOf(result), + startedAt: result.run.startedAt, + endedAt: result.run.endedAt, + stats: result.run.stats, + configModel: deps.agentModel, + note: result.kind === 'prepared' ? null : result.reason, + })); +} + +function outcomeOf(result: PrepareResult): RunOutcome { + switch (result.kind) { + case 'prepared': + return 'prepared'; + case 'skipped': + return 'skipped'; + case 'failed': + if (result.failure === 'timeout') return 'timeout'; + if (result.failure === 'rate-limit') return 'rate-limited'; + return 'failed'; + } +} + function prToRef(pr: InboxPr): PrRef { return { owner: pr.owner, repo: pr.repo, number: pr.number }; } diff --git a/packages/cli/src/inbox/view.ts b/packages/cli/src/inbox/view.ts index 970ea18..287e97a 100644 --- a/packages/cli/src/inbox/view.ts +++ b/packages/cli/src/inbox/view.ts @@ -1,6 +1,16 @@ -import { isRetired, type InboxPr, type InboxStore } from './store.js'; +import { isRetired, type InboxPr, type InboxStore, type RunTotals } from './store.js'; +import { costOf, minutesOf, runDetail } from './runs.js'; import { BUMPABLE } from './open.js'; +/** What the agent runs behind a prepared review came to, as its card shows it. */ +export interface RunSpend { + minutes: number; + /** null when no run behind this head reported a cost. */ + costUsd: number | null; + /** One line per run — phase, model, turns, tokens — for the hover. */ + detail: string; +} + /** One row as the inbox surface shows it: what it is, what was done, and whether it needs a look. */ export interface InboxRow { id: string; @@ -29,6 +39,8 @@ export interface InboxRow { /** Where a POST bumps it to the front of the queue; null unless it is queued, skipped or failed and not bumped already. */ prepareUrl: string | null; bumped: boolean; + /** What the prepared review cost in agent runs; null when none were recorded for that head. */ + spend: RunSpend | null; } export interface InboxView { @@ -41,20 +53,42 @@ export interface InboxView { /** Set aside by the reviewer; listed so a bump can bring one back. */ dismissed: InboxRow[]; generatedAt: string; + /** What the agent has spent, today and over the last seven days. */ + runs: { today: RunTotals; week: RunTotals }; + /** Until when preparation is held back — the reviewer's Claude limit — or null when it is not. */ + pausedUntil: string | null; } export function buildView(store: InboxStore, openBase: string, now: string): InboxView { - const rows = store.all().map(pr => toRow(pr, openBase)); + const rows = store.all().map(pr => toRow(pr, openBase, store)); const ready = rows.filter(row => row.status === 'prepared' || row.status === 'stale') .sort((a, b) => diffSize(a) - diffSize(b)); const working = rows.filter(row => row.status === 'queued' || row.status === 'preparing') .sort((a, b) => Number(b.bumped) - Number(a.bumped)); const dismissed = rows.filter(row => row.status === 'dismissed'); const other = rows.filter(row => !ready.includes(row) && !working.includes(row) && !dismissed.includes(row) && !isRetired(row.status)); - return { ready, working, other, dismissed, generatedAt: now }; + return { ready, working, other, dismissed, generatedAt: now, runs: runWindows(store, now), pausedUntil: store.pausedUntil(now) }; +} + +/** The two windows the footer shows, on the reviewer's own clock. */ +function runWindows(store: InboxStore, now: string): { today: RunTotals; week: RunTotals } { + const at = Number.isNaN(Date.parse(now)) ? new Date() : new Date(now); + const midnight = new Date(at.getFullYear(), at.getMonth(), at.getDate()); + const weekAgo = new Date(at.getTime() - 7 * 24 * 60 * 60 * 1000); + return { today: store.runTotals(midnight.toISOString()), week: store.runTotals(weekAgo.toISOString()) }; +} + +function spendOf(store: InboxStore, pr: InboxPr): RunSpend | null { + if (pr.preparedHeadSha === null) { + return null; + } + const runs = store.latestRunsFor(pr.id, pr.preparedHeadSha); + return runs.length === 0 + ? null + : { minutes: minutesOf(runs), costUsd: costOf(runs), detail: runs.map(runDetail).join('\n') }; } -function toRow(pr: InboxPr, openBase: string): InboxRow { +function toRow(pr: InboxPr, openBase: string, store: InboxStore): InboxRow { const stale = pr.status === 'stale' || (pr.status === 'prepared' && pr.preparedHeadSha != null && pr.preparedHeadSha !== pr.headSha); const openable = pr.status === 'prepared' || pr.status === 'stale'; @@ -81,6 +115,7 @@ function toRow(pr: InboxPr, openBase: string): InboxRow { dismissUrl: pr.status === 'preparing' || pr.status === 'dismissed' || isRetired(pr.status) ? null : `${openBase}/dismiss/${encodeURIComponent(pr.id)}`, prepareUrl: bumpable && pr.bumpedAt === null ? `${openBase}/prepare/${encodeURIComponent(pr.id)}` : null, bumped: pr.bumpedAt !== null, + spend: spendOf(store, pr), }; } diff --git a/packages/cli/tests/inbox-agent.test.ts b/packages/cli/tests/inbox-agent.test.ts index da54806..ada0ff0 100644 --- a/packages/cli/tests/inbox-agent.test.ts +++ b/packages/cli/tests/inbox-agent.test.ts @@ -5,7 +5,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import { buildAgentArgv, shellQuote, skillBody } from '../src/inbox/agent-argv.js'; -import { parseAgentOutput } from '../src/inbox/agent-output.js'; +import { parseAgentOutput, rateLimitOf } from '../src/inbox/agent-output.js'; import { allowFromEnv, mcpGateDecision } from '../src/inbox/mcp-gate.js'; import { DEFAULT_INBOX_CONFIG, type AgentConfig } from '../src/inbox/config.js'; @@ -265,3 +265,73 @@ describe('parseAgentOutput', () => { expect(parseAgentOutput('')).toEqual({ text: '', stats: null }); }); }); + +describe('rateLimitOf', () => { + const limit = "Claude AI usage limit reached. You've hit your session limit \u00b7 resets 2pm (Europe/Stockholm)"; + + it('says nothing about a run that did not hit a limit', () => { + expect(rateLimitOf('reviewing\nPREPARED', new Date('2026-09-07T09:00:00Z'))).toBeNull(); + expect(rateLimitOf('the rate of change here is high', new Date('2026-09-07T09:00:00Z'))).toBeNull(); + }); + + it('reads the reset time in the zone the message named, today or tomorrow', () => { + // 11:00 in Stockholm, so 14:00 there is still ahead: today, 12:00 UTC. + expect(rateLimitOf(limit, new Date('2026-09-07T09:00:00Z'))).toEqual({ resetsAt: '2026-09-07T12:00:00.000Z' }); + // 15:00 in Stockholm: the next 14:00 there is tomorrow. + expect(rateLimitOf(limit, new Date('2026-09-07T13:00:00Z'))).toEqual({ resetsAt: '2026-09-08T12:00:00.000Z' }); + }); + + it('follows the zone through its winter offset', () => { + expect(rateLimitOf(limit, new Date('2026-01-15T09:00:00Z'))).toEqual({ resetsAt: '2026-01-15T13:00:00.000Z' }); + }); + + it('reads a 24-hour time and a time with minutes', () => { + const stockholm = "You've hit your usage limit, resets 06:30 (Europe/Stockholm)"; + expect(rateLimitOf(stockholm, new Date('2026-09-07T00:00:00Z'))).toEqual({ resetsAt: '2026-09-07T04:30:00.000Z' }); + const newYork = "You've hit your session limit \u00b7 resets 2:15pm (America/New_York)"; + expect(rateLimitOf(newYork, new Date('2026-09-07T10:00:00Z'))).toEqual({ resetsAt: '2026-09-07T18:15:00.000Z' }); + }); + + it('reads a time with no zone on the reviewer\'s own clock', () => { + const now = new Date('2026-09-07T09:00:00Z'); + const parsed = rateLimitOf("You've hit your session limit \u00b7 resets 23:45", now); + const at = new Date(parsed!.resetsAt!); + expect([at.getHours(), at.getMinutes()]).toEqual([23, 45]); + expect(at.getTime()).toBeGreaterThan(now.getTime()); + }); + + it('reads a time introduced with "at"', () => { + expect(rateLimitOf("You've hit your session limit \u00b7 resets at 2pm (Europe/Stockholm)", new Date('2026-09-07T09:00:00Z'))) + .toEqual({ resetsAt: '2026-09-07T12:00:00.000Z' }); + expect(rateLimitOf("You've hit your usage limit, resets at 06:30 (Europe/Stockholm)", new Date('2026-09-07T00:00:00Z'))) + .toEqual({ resetsAt: '2026-09-07T04:30:00.000Z' }); + }); + + it('counts forward from now when the message says how long is left', () => { + const now = new Date('2026-09-07T09:00:00Z'); + expect(rateLimitOf("You've hit your session limit \u00b7 resets in 3 hours", now)).toEqual({ resetsAt: '2026-09-07T12:00:00.000Z' }); + expect(rateLimitOf("You've hit your usage limit, resets in 45 minutes", now)).toEqual({ resetsAt: '2026-09-07T09:45:00.000Z' }); + expect(rateLimitOf("You've hit your session limit \u00b7 resets in 1 hour 30 minutes", now)).toEqual({ resetsAt: '2026-09-07T10:30:00.000Z' }); + expect(rateLimitOf("You've hit your session limit \u00b7 resets in 90 mins", now)).toEqual({ resetsAt: '2026-09-07T10:30:00.000Z' }); + expect(rateLimitOf("You've hit your session limit \u00b7 resets in 1 hr", now)).toEqual({ resetsAt: '2026-09-07T10:00:00.000Z' }); + }); + + it('flags the limit with no time when the message names none it can read', () => { + for (const text of [ + "You've hit your session limit", + "You've hit your session limit \u00b7 resets soon", + "You've hit your session limit \u00b7 resets in a while", + "You've hit your session limit \u00b7 resets 25:00", + "You've hit your session limit \u00b7 resets 13pm", + ]) { + expect(rateLimitOf(text, new Date('2026-09-07T09:00:00Z'))).toEqual({ resetsAt: null }); + } + }); + + it('falls back to the local clock when the zone is not one Intl knows', () => { + const now = new Date('2026-09-07T09:00:00Z'); + const parsed = rateLimitOf("You've hit your session limit \u00b7 resets 2pm (Middle/Earth)", now); + const at = new Date(parsed!.resetsAt!); + expect([at.getHours(), at.getMinutes()]).toEqual([14, 0]); + }); +}); diff --git a/packages/cli/tests/inbox-attendant.test.ts b/packages/cli/tests/inbox-attendant.test.ts index e32d953..ea30d94 100644 --- a/packages/cli/tests/inbox-attendant.test.ts +++ b/packages/cli/tests/inbox-attendant.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { LiveRequest } from '@diffity/api'; import { Attendants, composeLivePrompt, parseAwaitOutcome, type AttendantDeps, type AwaitOutcome } from '../src/inbox/attendant.js'; -const pr = { id: 'o/r#4', url: 'https://github.com/o/r/pull/4', title: 'A change', author: 'alice' }; +const pr = { id: 'o/r#4', url: 'https://github.com/o/r/pull/4', title: 'A change', author: 'alice', headSha: 'aaa' }; function request(over: Partial = {}): LiveRequest { return { @@ -13,7 +13,7 @@ function request(over: Partial = {}): LiveRequest { /** Deps that hand out a scripted sequence of outcomes and record what was asked of them. */ function scripted(outcomes: AwaitOutcome[], answerEndsWith: { timedOut: boolean } = { timedOut: false }) { - const answers: { worktree: string; prompt: string }[] = []; + const answers: { worktree: string; prId: string; prompt: string }[] = []; const givenUp: { commentId: string; note: string }[] = []; const logs: string[] = []; let answerGate: (() => void) | null = null; @@ -29,8 +29,8 @@ function scripted(outcomes: AwaitOutcome[], answerEndsWith: { timedOut: boolean waits.push(() => resolve({ kind: 'failed', reason: 'stopped' })); signal.addEventListener('abort', () => resolve({ kind: 'failed', reason: 'stopped' }), { once: true }); }), - answer: (worktree, prompt) => new Promise<{ timedOut: boolean }>(resolve => { - answers.push({ worktree, prompt }); + answer: (worktree, answered, prompt) => new Promise<{ timedOut: boolean }>(resolve => { + answers.push({ worktree, prId: answered.id, prompt }); answerGate = () => resolve(answerEndsWith); }), giveUp: (_worktree, request, note) => { givenUp.push({ commentId: request.commentId, note }); return Promise.resolve(); }, @@ -50,6 +50,8 @@ describe('an attendant', () => { expect(script.answers).toHaveLength(1); expect(script.answers[0].worktree).toBe('/wt'); + // The answering agent is told which pull request it is answering about, so its run is logged. + expect(script.answers[0].prId).toBe('o/r#4'); expect(script.answers[0].prompt).toContain('why is this safe?'); expect(script.answers[0].prompt).toContain('agent reply t1 --aside --answers c1'); // The next wait is already parked while the answer is still running. diff --git a/packages/cli/tests/inbox-daemon.test.ts b/packages/cli/tests/inbox-daemon.test.ts index f297c86..3935111 100644 --- a/packages/cli/tests/inbox-daemon.test.ts +++ b/packages/cli/tests/inbox-daemon.test.ts @@ -151,4 +151,39 @@ describe('runDaemon singleton and reclaim ordering', () => { await handle.stop(); } }); + + it('polls but prepares nothing while paused, and takes the queue up once the pause has passed', async () => { + const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); + const snap: PrSnapshot = { + owner: 'o', repo: 'r', number: 1, title: 'T', url: 'https://github.com/o/r/pull/1', author: 'alice', isBot: false, + isDraft: false, state: 'OPEN', headSha: 'aaa', baseRef: 'main', additions: 1, deletions: 0, changedFiles: 1, + createdAt: 'now', updatedAt: 'now', + }; + store.observe(snap, true, 'now'); + store.pauseUntil(new Date(Date.now() + 60_000).toISOString()); + const forge: Forge = { + viewerLogin: () => Promise.resolve('me'), + searchReviewRequested: () => Promise.resolve([{ owner: 'o', repo: 'r', number: 1 }]), + viewPr: () => Promise.resolve(snap), + }; + const handle = await runDaemon(store, config(6006), process.execPath, 'unused-entry', () => {}, { forge }); + try { + await settle(); + const paused = await (await fetch('http://127.0.0.1:6006/api/inbox')).json(); + expect(paused.pausedUntil).not.toBeNull(); + expect(store.get('o/r#1')!.status).toBe('queued'); + + // The reset time passes: the next tick prepares it (and fails, for want of a clone). + store.pauseUntil(new Date(Date.now() - 1000).toISOString()); + expect((await fetch('http://127.0.0.1:6006/api/tick', { method: 'POST' })).status).toBe(204); + await settle(); + await settle(); + + expect(store.get('o/r#1')!.status).toBe('failed'); + const resumed = await (await fetch('http://127.0.0.1:6006/api/inbox')).json(); + expect(resumed.pausedUntil).toBeNull(); + } finally { + await handle.stop(); + } + }); }); diff --git a/packages/cli/tests/inbox-open.test.ts b/packages/cli/tests/inbox-open.test.ts index 44aa3a7..7b645c6 100644 --- a/packages/cli/tests/inbox-open.test.ts +++ b/packages/cli/tests/inbox-open.test.ts @@ -384,7 +384,7 @@ describe('the inbox server routes', () => { let ticking = false; const { port, server } = await serve(store, [], null, null, undefined, { onTick: () => { ticks++; ticking = true; }, - status: () => ({ ticking, lastPollAt: ticking ? null : '2026-09-07T10:00:00Z' }), + status: () => ({ ticking, lastPollAt: ticking ? null : '2026-09-07T10:00:00Z', pausedUntil: null }), }); try { const before = await (await fetch(`http://127.0.0.1:${port}/api/inbox`)).json(); diff --git a/packages/cli/tests/inbox-page.test.ts b/packages/cli/tests/inbox-page.test.ts index e52f521..b55bd9b 100644 --- a/packages/cli/tests/inbox-page.test.ts +++ b/packages/cli/tests/inbox-page.test.ts @@ -30,6 +30,16 @@ describe('the inbox page', () => { expect(html).toContain('id="agentMaxBudgetUsd" type="number" min="0.5"'); }); + it('shows what a prepared review spent, the totals, and the pause', () => { + const script = pageScript(); + expect(script).toContain("Math.round(r.spend.minutes) + ' min"); + expect(script).toContain("'agent runs today: '"); + expect(script).toContain("'preparing paused until '"); + // The cost hangs off the meta line, which is the part that gives: a long one cannot widen the card. + expect(script).toContain("r.spend ? r.spend.detail : ''"); + expect(inboxPage()).toContain('.title .meta { color: var(--muted); font-size: 12px; min-width: 0;'); + }); + it('sends the agent block back with the settings, empty fields as null', () => { const script = pageScript(); expect(script).toContain("model: el('agentModel').value.trim() || null"); diff --git a/packages/cli/tests/inbox-prepare.test.ts b/packages/cli/tests/inbox-prepare.test.ts index 3395ce6..25e0bcd 100644 --- a/packages/cli/tests/inbox-prepare.test.ts +++ b/packages/cli/tests/inbox-prepare.test.ts @@ -116,7 +116,7 @@ describe('preparePr', () => { })); expect(result.kind).toBe('prepared'); - expect(result.stats).toMatchObject({ costUsd: 1.25, turns: 12, outputTokens: 900, models: ['claude-x'], subtype: 'success' }); + expect(result.run.stats).toMatchObject({ costUsd: 1.25, turns: 12, outputTokens: 900, models: ['claude-x'], subtype: 'success' }); }); it('fails with the budget as the reason when the agent hit it', async () => { @@ -130,6 +130,7 @@ describe('preparePr', () => { expect(result.kind).toBe('failed'); if (result.kind !== 'failed') return; + expect(result.failure).toBe('budget'); expect(result.reason).toBe('the agent hit its budget of $3'); expect(existsSync(dest)).toBe(false); }); @@ -163,6 +164,8 @@ describe('preparePr', () => { const result = await preparePr(snapshot(), cfg, deps()); expect(result.kind).toBe('failed'); if (result.kind !== 'failed') return; + // Nothing was run, so the tick has no run to log for it. + expect(result.failure).toBe('worktree'); expect(result.reason).toContain('No local clone'); }); @@ -173,10 +176,51 @@ describe('preparePr', () => { })); expect(result.kind).toBe('failed'); if (result.kind !== 'failed') return; + expect(result.failure).toBe('timeout'); expect(result.reason).toContain('did not finish'); expect(existsSync(dest)).toBe(false); }); + it('says when the agent started and ended, around the run itself', async () => { + const clock = [ + '2026-09-07T11:59:00.000Z', '2026-09-07T12:00:00.000Z', + '2026-09-07T12:08:00.000Z', '2026-09-07T12:08:01.000Z', + ]; + const result = await preparePr(snapshot(), config(), deps({ now: () => clock.shift() ?? 'later' })); + + expect(result.run).toEqual({ startedAt: '2026-09-07T12:00:00.000Z', endedAt: '2026-09-07T12:08:00.000Z', stats: null }); + expect(result.kind === 'prepared' && result.at).toBe('2026-09-07T12:08:01.000Z'); + }); + + it('waits out a session limit instead of holding it against the pull request', async () => { + const dest = worktreePath(worktreesDir, snapshot()); + const result = await preparePr(snapshot(), config(), deps({ + runAgent: () => Promise.resolve({ + stdout: "Claude AI usage limit reached. You've hit your session limit \u00b7 resets 2pm (Europe/Stockholm)\n", + timedOut: false, + }), + })); + + expect(result.kind).toBe('failed'); + if (result.kind !== 'failed') return; + expect(result.failure).toBe('rate-limit'); + expect(result.reason).toMatch(/^waiting: Claude session limit until \d\d:\d\d$/); + expect(result.resetsAt).toMatch(/^20\d\d-\d\d-\d\dT/); + expect(existsSync(dest)).toBe(false); + }); + + it('leaves the retry to the caller when the limit named no time', async () => { + const result = await preparePr(snapshot(), config(), deps({ + runAgent: () => Promise.resolve({ + stdout: JSON.stringify({ type: 'result', subtype: 'success', is_error: true, result: "You've hit your usage limit" }), + timedOut: false, + }), + })); + + expect(result.kind === 'failed' && [result.failure, result.reason, result.resetsAt]) + .toEqual(['rate-limit', 'waiting: Claude session limit, retrying in 30 minutes', null]); + }); + it('always stops the diffity server, even on a failure', async () => { let stopped = 0; await preparePr(snapshot(), config(), deps({ @@ -221,6 +265,36 @@ describe('the inbox JSON server', () => { store.close(); }); + it('carries what the agent spent on a prepared review, and the pause, to the page', async () => { + const store = new InboxStore(':memory:'); + store.observe({ ...snapshot(), headSha: 'aaa' }, true, 'now'); + store.markPrepared('o/demo#4', { headSha: 'aaa', bundlePath: '/b.json', worktreePath: '/wt', logPath: '/l', at: 'now', summary: '1 P1', alert: null }); + store.recordRun({ + prId: 'o/demo#4', headSha: 'aaa', phase: 'prepare', model: 'claude-x', + startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), durationMs: 480_000, + turns: 12, costUsd: 1.2, inputTokens: 30, outputTokens: 27_000, cacheReadTokens: 1_100_000, + cacheWriteTokens: 76_000, outcome: 'prepared', note: null, + }); + const until = new Date(Date.now() + 60_000).toISOString(); + store.pauseUntil(until); + const noOpenDeps = { baseRefOf: () => 'x', ensureServer: () => Promise.resolve(1), importBundle: () => {} }; + const server = startInboxServer(store, { ...config(), port: 0 }, () => {}, noOpenDeps); + await new Promise(resolve => server.on('listening', resolve)); + const { port } = server.address() as { port: number }; + + const body = await (await fetch(`http://127.0.0.1:${port}/api/inbox`)).json(); + + expect(body.ready[0].spend).toEqual({ + minutes: 8, costUsd: 1.2, detail: 'prepare · claude-x · 12 turns · out 27k · read 1.1M', + }); + expect(body.runs.today).toEqual({ count: 1, minutes: 8, costUsd: 1.2 }); + expect(body.runs.week).toEqual({ count: 1, minutes: 8, costUsd: 1.2 }); + expect(body.pausedUntil).toBe(until); + + server.close(); + store.close(); + }); + it('sets the filter aside for a bumped pull request', async () => { prompts = []; const withFilter = { ...config(), filter: 'Skip payments-focused PRs' }; diff --git a/packages/cli/tests/inbox-runs.test.ts b/packages/cli/tests/inbox-runs.test.ts new file mode 100644 index 0000000..ba431f5 --- /dev/null +++ b/packages/cli/tests/inbox-runs.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { InboxStore, type RunRecord } from '../src/inbox/store.js'; + +const DIST_ENTRY = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'index.js'); + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'diffity-runs-')); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function record(over: Partial = {}): RunRecord { + const startedAt = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + return { + prId: 'o/r#1', headSha: 'aaa', phase: 'prepare', model: 'claude-x', + startedAt, endedAt: startedAt, durationMs: 480_000, turns: 12, costUsd: 1.2, + inputTokens: 30, outputTokens: 27_000, cacheReadTokens: 1_100_000, cacheWriteTokens: 76_000, + outcome: 'prepared', note: null, ...over, + }; +} + +/** Runs seeded into the store the command reads, under its own data directory. */ +function seed(...rows: RunRecord[]): void { + const store = new InboxStore(join(root, 'inbox', 'inbox.sqlite')); + for (const row of rows) { + store.recordRun(row); + } + store.close(); +} + +function runs(args: string[]): Promise<{ code: number | null; stdout: string }> { + return new Promise(resolve => { + const child = spawn(process.execPath, [DIST_ENTRY, 'inbox', 'runs', ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, DIFFITY_DATA_DIR: root }, + }); + let stdout = ''; + child.stdout.setEncoding('utf-8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.resume(); + child.on('close', code => resolve({ code, stdout })); + }); +} + +describe('diffity inbox runs', () => { + it('lists the runs with what they spent, and adds them up', async () => { + seed( + record(), + record({ phase: 'answer', outcome: 'answered', model: 'claude-y', turns: 3, costUsd: 0.4, durationMs: 60_000, outputTokens: 500, cacheReadTokens: 40_000 }), + ); + + const { code, stdout } = await runs([]); + + expect(code).toBe(0); + expect(stdout).toContain('when'); + expect(stdout).toContain('o/r#1'); + expect(stdout).toMatch(/prepare .*claude-x .*12 .*8\.0 .*\$1\.20 .*out 27k · read 1\.1M .*prepared/); + expect(stdout).toMatch(/answer .*claude-y .*3 .*1\.0 .*\$0\.40 .*out 500 · read 40k .*answered/); + expect(stdout).toContain('2 runs · 9 min · $1.60 over the last 7 day(s)'); + }); + + it('leaves out what is older than the window, and says when there is nothing', async () => { + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + seed(record({ startedAt: old, endedAt: old })); + + expect((await runs(['--since', '2'])).stdout).toContain('No agent runs in the last 2 day(s).'); + expect((await runs(['--since', '40'])).stdout).toContain('o/r#1'); + }); + + it('prints the rows and the totals as JSON', async () => { + seed(record()); + + const { stdout } = await runs(['--json', '--since', '3']); + const body = JSON.parse(stdout); + + expect(body.days).toBe(3); + expect(body.totals).toEqual({ count: 1, minutes: 8, costUsd: 1.2 }); + expect(body.runs).toHaveLength(1); + expect(body.runs[0]).toMatchObject({ prId: 'o/r#1', phase: 'prepare', outcome: 'prepared', model: 'claude-x', turns: 12 }); + }); + + it('refuses a window that is not a number of days', async () => { + expect((await runs(['--since', 'ages'])).code).toBe(1); + }); +}); diff --git a/packages/cli/tests/inbox-runtime.test.ts b/packages/cli/tests/inbox-runtime.test.ts index 1dadb89..4aef2b1 100644 --- a/packages/cli/tests/inbox-runtime.test.ts +++ b/packages/cli/tests/inbox-runtime.test.ts @@ -3,6 +3,9 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { realAttendantDeps, runAgent, startDiffityServer } from '../src/inbox/runtime.js'; +import type { AttendedPr } from '../src/inbox/attendant.js'; +import type { InboxConfig } from '../src/inbox/config.js'; +import type { RunRecord } from '../src/inbox/store.js'; let root: string; @@ -14,6 +17,37 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }); }); +function attendedPr(): AttendedPr { + return { id: 'o/r#4', url: 'https://github.com/o/r/pull/4', title: 'A change', author: 'alice', headSha: 'aaa' }; +} + +function liveConfig(): InboxConfig { + return { + pollMinutes: 5, port: 0, reposDir: root, worktreesDir: root, filter: '', alertWhen: '', + agent: { model: 'the-configured-model', effort: null, mcpAllow: [], extraArgs: [], maxBudgetUsd: null }, + prepareTimeoutMinutes: 30, maxPrepared: 5, live: true, liveTimeoutMinutes: 10, + }; +} + +/** A `claude` on PATH for the length of the call, with the reviewer's data directory redirected. */ +async function withStandInAgent(bin: string, run: () => Promise): Promise { + const path = process.env.PATH; + const dataDir = process.env.DIFFITY_DATA_DIR; + process.env.PATH = `${bin}:${path ?? ''}`; + // An answer runs in the reviewer's own data directory, which here must not be their real one. + process.env.DIFFITY_DATA_DIR = join(root, 'data'); + try { + await run(); + } finally { + process.env.PATH = path; + if (dataDir === undefined) { + delete process.env.DIFFITY_DATA_DIR; + } else { + process.env.DIFFITY_DATA_DIR = dataDir; + } + } +} + function opts(argv: string[], over: Partial[0]> = {}) { return { argv, @@ -106,34 +140,61 @@ describe('realAttendantDeps', () => { mkdirSync(bin, { recursive: true }); writeFileSync(join(bin, 'claude'), `#!/bin/sh\nexec '${process.execPath}' '${dump}' "$@"\n`, { mode: 0o755 }); - const config = { - pollMinutes: 5, port: 0, reposDir: root, worktreesDir: root, filter: '', alertWhen: '', - agent: { model: null, effort: null, mcpAllow: [], extraArgs: [], maxBudgetUsd: null }, - prepareTimeoutMinutes: 30, maxPrepared: 5, live: true, liveTimeoutMinutes: 10, - }; - const deps = realAttendantDeps(process.execPath, entry, config, () => join(root, 'live.log'), () => {}); - - const path = process.env.PATH; - const dataDir = process.env.DIFFITY_DATA_DIR; - process.env.PATH = `${bin}:${path ?? ''}`; - // An answer runs in the reviewer's own data directory, which here must not be their real one. - process.env.DIFFITY_DATA_DIR = join(root, 'data'); - try { - await deps.answer(root, 'the live prompt\n', new AbortController().signal); - } finally { - process.env.PATH = path; - if (dataDir === undefined) { - delete process.env.DIFFITY_DATA_DIR; - } else { - process.env.DIFFITY_DATA_DIR = dataDir; - } - } + const deps = realAttendantDeps(process.execPath, entry, liveConfig(), () => join(root, 'live.log'), () => {}); + + await withStandInAgent(bin, () => deps.answer(root, attendedPr(), 'the live prompt\n', new AbortController().signal).then(() => {})); const argv = JSON.parse(readFileSync(argvPath, 'utf-8')) as string[]; const at = argv.indexOf('--append-system-prompt'); expect(at).toBeGreaterThan(-1); expect(argv[at + 1]).toBe('# Diffity Live Skill\n\nAnswer it.\n'); }); + + it('logs the answer as a run against the pull request it was asked about', async () => { + mkdirSync(join(root, 'skills', 'diffity-live'), { recursive: true }); + writeFileSync(join(root, 'skills', 'diffity-live', 'SKILL.md'), '---\nname: diffity-live\n---\n\nAnswer it.\n'); + const result = JSON.stringify({ + type: 'result', subtype: 'success', is_error: false, result: 'answered', total_cost_usd: 0.4, + duration_ms: 90_000, num_turns: 3, usage: { output_tokens: 500 }, modelUsage: { 'claude-x': {} }, + }); + const bin = join(root, 'bin'); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, 'claude'), `#!/bin/sh\ncat > /dev/null\ncat <<'JSON'\n${result}\nJSON\n`, { mode: 0o755 }); + + const runs: RunRecord[] = []; + const deps = realAttendantDeps(process.execPath, join(root, 'index.js'), liveConfig(), () => join(root, 'live.log'), () => {}, run => runs.push(run)); + + await withStandInAgent(bin, () => deps.answer(root, attendedPr(), 'the live prompt\n', new AbortController().signal).then(() => {})); + + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + prId: 'o/r#4', headSha: 'aaa', phase: 'answer', outcome: 'answered', model: 'claude-x', + turns: 3, costUsd: 0.4, durationMs: 90_000, outputTokens: 500, + }); + }); + + it('logs an answer the daemon stopped as a failure rather than an answer', async () => { + mkdirSync(join(root, 'skills', 'diffity-live'), { recursive: true }); + writeFileSync(join(root, 'skills', 'diffity-live', 'SKILL.md'), '---\nname: diffity-live\n---\n\nAnswer it.\n'); + const bin = join(root, 'bin'); + mkdirSync(bin, { recursive: true }); + // Takes the prompt and then hangs, so the abort is what ends it — and a killed child closes + // as cleanly as one that answered. + writeFileSync(join(bin, 'claude'), '#!/bin/sh\ncat > /dev/null\nsleep 30\n', { mode: 0o755 }); + + const runs: RunRecord[] = []; + const deps = realAttendantDeps(process.execPath, join(root, 'index.js'), liveConfig(), () => join(root, 'live.log'), () => {}, run => runs.push(run)); + const control = new AbortController(); + + await withStandInAgent(bin, async () => { + const answering = deps.answer(root, attendedPr(), 'the live prompt\n', control.signal); + setTimeout(() => control.abort(), 300); + await answering; + }); + + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ prId: 'o/r#4', phase: 'answer', outcome: 'failed', note: 'stopped before it answered' }); + }); }); describe('startDiffityServer', () => { diff --git a/packages/cli/tests/inbox-store.test.ts b/packages/cli/tests/inbox-store.test.ts index 055f8cd..3a335a7 100644 --- a/packages/cli/tests/inbox-store.test.ts +++ b/packages/cli/tests/inbox-store.test.ts @@ -3,7 +3,8 @@ import { DatabaseSync } from 'node:sqlite'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { InboxStore } from '../src/inbox/store.js'; +import { InboxStore, runRecordOf, type RunRecord } from '../src/inbox/store.js'; +import type { RunStats } from '../src/inbox/agent-output.js'; import type { PrSnapshot } from '@diffity/github'; let dir: string; @@ -99,3 +100,103 @@ describe('InboxStore migration', () => { store.close(); }); }); + +describe('the run log', () => { + function record(over: Partial = {}): RunRecord { + return { + prId: 'o/r#1', headSha: 'aaa', phase: 'prepare', model: 'claude-x', + startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:08:00.000Z', + durationMs: 480_000, turns: 12, costUsd: 1.2, inputTokens: 30, outputTokens: 27_000, + cacheReadTokens: 1_100_000, cacheWriteTokens: 76_000, outcome: 'prepared', note: null, ...over, + }; + } + + it('keeps a run whole and lists them newest first', () => { + const store = new InboxStore(path); + store.recordRun(record()); + store.recordRun(record({ phase: 'answer', outcome: 'answered', startedAt: '2026-09-07T10:00:00.000Z', endedAt: '2026-09-07T10:01:00.000Z' })); + + const rows = store.runs(); + expect(rows.map(row => row.phase)).toEqual(['answer', 'prepare']); + expect(rows[1]).toEqual({ id: 1, ...record() }); + store.close(); + }); + + it('lists one pull request, or one window, at a time', () => { + const store = new InboxStore(path); + store.recordRun(record({ startedAt: '2026-09-01T09:00:00.000Z' })); + store.recordRun(record({ prId: 'o/r#2', startedAt: '2026-09-07T09:00:00.000Z' })); + + expect(store.runs({ prId: 'o/r#2' }).map(row => row.prId)).toEqual(['o/r#2']); + expect(store.runs({ since: '2026-09-05T00:00:00.000Z' }).map(row => row.prId)).toEqual(['o/r#2']); + expect(store.runs({ since: '2026-09-05T00:00:00.000Z', prId: 'o/r#1' })).toEqual([]); + store.close(); + }); + + it('adds up the minutes and the costs it knows, from the window given', () => { + const store = new InboxStore(path); + store.recordRun(record({ durationMs: 480_000, costUsd: 1.2 })); + store.recordRun(record({ durationMs: 120_000, costUsd: null })); + store.recordRun(record({ startedAt: '2026-08-01T09:00:00.000Z', durationMs: 600_000, costUsd: 9 })); + + expect(store.runTotals('2026-09-01T00:00:00.000Z')).toEqual({ count: 2, minutes: 10, costUsd: 1.2 }); + expect(store.runTotals('2026-09-08T00:00:00.000Z')).toEqual({ count: 0, minutes: 0, costUsd: 0 }); + store.close(); + }); + + it('finds the runs behind one prepared head, oldest first', () => { + const store = new InboxStore(path); + store.recordRun(record({ phase: 'prepare' })); + store.recordRun(record({ phase: 'answer', outcome: 'answered', startedAt: '2026-09-07T10:00:00.000Z' })); + store.recordRun(record({ headSha: 'bbb', startedAt: '2026-09-07T11:00:00.000Z' })); + + expect(store.latestRunsFor('o/r#1', 'aaa').map(row => row.phase)).toEqual(['prepare', 'answer']); + expect(store.latestRunsFor('o/r#1', 'ccc')).toEqual([]); + store.close(); + }); +}); + +describe('runRecordOf', () => { + function stats(over: Partial = {}): RunStats { + return { + costUsd: 1.2, durationMs: 480_000, turns: 12, inputTokens: 30, outputTokens: 27_000, + cacheReadTokens: 1_100_000, cacheWriteTokens: 76_000, models: ['claude-x'], isError: false, + subtype: 'success', ...over, + }; + } + + const shape = { + prId: 'o/r#1', headSha: 'aaa', phase: 'prepare' as const, outcome: 'prepared' as const, + startedAt: '2026-09-07T09:00:00.000Z', endedAt: '2026-09-07T09:10:00.000Z', configModel: 'opus', + }; + + it('takes the models and the duration the run reported', () => { + expect(runRecordOf({ ...shape, stats: stats({ models: ['claude-x', 'claude-haiku'] }) })) + .toMatchObject({ model: 'claude-x,claude-haiku', durationMs: 480_000, turns: 12, costUsd: 1.2, outputTokens: 27_000 }); + }); + + it('falls back to the configured model and to the wall clock the daemon measured', () => { + expect(runRecordOf({ ...shape, stats: stats({ models: [], durationMs: null }) })) + .toMatchObject({ model: 'opus', durationMs: 600_000 }); + expect(runRecordOf({ ...shape, stats: null })) + .toMatchObject({ model: 'opus', durationMs: 600_000, turns: null, costUsd: null, outputTokens: null }); + expect(runRecordOf({ ...shape, stats: null, configModel: null }).model).toBeNull(); + }); +}); + +describe('the preparing pause', () => { + it('reads back a moment still ahead, and not one already past', () => { + const store = new InboxStore(path); + expect(store.pausedUntil('2026-09-07T12:00:00.000Z')).toBeNull(); + + store.pauseUntil('2026-09-07T14:00:00.000Z'); + expect(store.pausedUntil('2026-09-07T12:00:00.000Z')).toBe('2026-09-07T14:00:00.000Z'); + expect(store.pausedUntil('2026-09-07T14:00:01.000Z')).toBeNull(); + + store.pauseUntil('2026-09-07T15:00:00.000Z'); + expect(store.pausedUntil('2026-09-07T14:30:00.000Z')).toBe('2026-09-07T15:00:00.000Z'); + store.pauseUntil(null); + expect(store.pausedUntil('2026-09-07T14:30:00.000Z')).toBeNull(); + store.close(); + }); +}); diff --git a/packages/cli/tests/inbox-tick.test.ts b/packages/cli/tests/inbox-tick.test.ts index 1c08258..0a7a475 100644 --- a/packages/cli/tests/inbox-tick.test.ts +++ b/packages/cli/tests/inbox-tick.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { InboxStore, prId } from '../src/inbox/store.js'; import { runTick, type Forge, type TickDeps } from '../src/inbox/tick.js'; import { buildView } from '../src/inbox/view.js'; +import { localHhMm } from '../src/inbox/runs.js'; import type { PrRef, PrSnapshot } from '@diffity/github'; -import type { PrepareResult } from '../src/inbox/prepare.js'; +import type { PrepareResult, RunLog } from '../src/inbox/prepare.js'; function snapshot(over: Partial = {}): PrSnapshot { return { @@ -13,6 +14,19 @@ function snapshot(over: Partial = {}): PrSnapshot { }; } +/** One agent run as `preparePr` reports it: two minutes, with what it spent. */ +function run(over: Partial = {}): RunLog { + return { + startedAt: '2026-09-02T11:58:00.000Z', + endedAt: '2026-09-02T12:00:00.000Z', + stats: { + costUsd: 1.2, durationMs: 120_000, turns: 9, inputTokens: 10, outputTokens: 2000, + cacheReadTokens: 5000, cacheWriteTokens: 100, models: ['claude-x'], isError: false, subtype: 'success', + }, + ...over, + }; +} + /** A forge whose answers each test sets, so a tick runs without touching gh. */ class FakeForge implements Forge { login: string | null = 'me'; @@ -36,6 +50,7 @@ let forge: FakeForge; let prepared: string[]; let bumpedFlags: boolean[]; let removed: string[]; +let pauses: string[]; let prepareResult: (snap: PrSnapshot) => PrepareResult; function deps(over: Partial = {}): TickDeps { @@ -46,6 +61,8 @@ function deps(over: Partial = {}): TickDeps { log: () => {}, now: () => '2026-09-02T12:00:00.000Z', maxPrepared: 100, + agentModel: 'the-configured-model', + pauseUntil: until => { pauses.push(until); }, ...over, }; } @@ -56,10 +73,11 @@ beforeEach(() => { prepared = []; bumpedFlags = []; removed = []; + pauses = []; prepareResult = (snap) => ({ kind: 'prepared', headSha: snap.headSha, bundlePath: `/b/${snap.number}.json`, worktree: `/wt/${snap.number}`, logPath: `/l/${snap.number}.log`, at: '2026-09-02T12:00:00.000Z', - summary: '1 P2', alert: snap.number === 2 ? 'touches auth' : null, stats: null, + summary: '1 P2', alert: snap.number === 2 ? 'touches auth' : null, run: run(), }); }); @@ -89,7 +107,7 @@ describe('runTick', () => { it('records a skip verdict without preparing again next tick', async () => { forge.set(snapshot()); - prepareResult = () => ({ kind: 'skipped', reason: 'payments PR', logPath: '/l/1.log', stats: null }); + prepareResult = () => ({ kind: 'skipped', reason: 'payments PR', logPath: '/l/1.log', run: run() }); await runTick(store, deps()); expect(store.get('o/r#1')!.status).toBe('skipped'); expect(store.get('o/r#1')!.statusReason).toBe('payments PR'); @@ -139,7 +157,7 @@ describe('runTick', () => { it('holds a failed preparation with its reason and log, and stops after the attempt cap', async () => { forge.set(snapshot()); - prepareResult = () => ({ kind: 'failed', reason: 'no local clone', worktree: null, logPath: '/l/1.log', stats: null }); + prepareResult = () => ({ kind: 'failed', failure: 'agent', reason: 'no local clone', worktree: null, logPath: '/l/1.log', run: run() }); for (let i = 0; i < 5; i++) { prepared = []; @@ -319,6 +337,94 @@ describe('runTick', () => { expect(store.get('o/r#2')!.status).toBe('queued'); }); + it('logs the preparation as a run, with the models it spent on', async () => { + forge.set(snapshot()); + await runTick(store, deps()); + + expect(store.runs({})).toHaveLength(1); + expect(store.runs({})[0]).toMatchObject({ + prId: 'o/r#1', headSha: 'aaa', phase: 'prepare', outcome: 'prepared', model: 'claude-x', + turns: 9, costUsd: 1.2, durationMs: 120_000, outputTokens: 2000, note: null, + }); + }); + + it('logs a skip and a timeout under their own outcomes, and nothing when no agent ran', async () => { + forge.set(snapshot()); + prepareResult = () => ({ kind: 'skipped', reason: 'payments PR', logPath: '/l/1.log', run: run() }); + await runTick(store, deps()); + expect(store.runs({}).map(row => [row.outcome, row.note])).toEqual([['skipped', 'payments PR']]); + + forge.snapshots.set('o/r#1', snapshot({ headSha: 'bbb' })); + prepareResult = () => ({ kind: 'failed', failure: 'timeout', reason: 'the agent did not finish within 30 minutes', worktree: null, logPath: '/l/1.log', run: run() }); + await runTick(store, deps()); + expect(store.runs({}).map(row => row.outcome)).toEqual(['timeout', 'skipped']); + + // A worktree that could not be cut never got as far as an agent, so there is no run to log. + forge.snapshots.set('o/r#1', snapshot({ headSha: 'ccc' })); + prepareResult = () => ({ kind: 'failed', failure: 'worktree', reason: 'No local clone', worktree: null, logPath: null, run: run() }); + await runTick(store, deps()); + expect(store.runs({}).map(row => row.outcome)).toEqual(['timeout', 'skipped']); + }); + + it('records the configured model when the run did not say which it used', async () => { + forge.set(snapshot()); + prepareResult = snap => ({ + kind: 'prepared', headSha: snap.headSha, bundlePath: '/b.json', worktree: '/wt', logPath: '/l.log', + at: '2026-09-02T12:00:00.000Z', summary: null, alert: null, run: run({ stats: null }), + }); + await runTick(store, deps()); + + // No stats at all: the wall clock the daemon measured stands in for the duration. + expect(store.runs({})[0]).toMatchObject({ model: 'the-configured-model', durationMs: 120_000, turns: null, costUsd: null }); + }); + + it('waits out a session limit: the row keeps its retries and preparing is paused', async () => { + forge.set(snapshot()); + prepareResult = () => ({ + kind: 'failed', failure: 'rate-limit', reason: 'waiting: Claude session limit until 14:00', + worktree: null, logPath: '/l/1.log', resetsAt: '2026-09-02T14:00:00.000Z', run: run(), + }); + await runTick(store, deps()); + + const pr = store.get('o/r#1')!; + expect(pr.status).toBe('queued'); + expect(pr.statusReason).toBe('waiting: Claude session limit until 14:00'); + expect(pr.attempts).toBe(0); + expect(pr.logPath).toBe('/l/1.log'); + expect(pauses).toEqual(['2026-09-02T14:00:00.000Z']); + expect(store.runs({})[0]).toMatchObject({ outcome: 'rate-limited', note: 'waiting: Claude session limit until 14:00' }); + }); + + it('pauses half an hour when the limit message named no time', async () => { + forge.set(snapshot()); + prepareResult = () => ({ + kind: 'failed', failure: 'rate-limit', reason: 'waiting: Claude session limit, retrying in 30 minutes', + worktree: null, logPath: null, resetsAt: null, run: run(), + }); + await runTick(store, deps()); + expect(pauses).toEqual(['2026-09-02T12:30:00.000Z']); + }); + + it('polls and reconciles while paused, but prepares nothing until it lifts', async () => { + forge.set(snapshot()); + const until = '2026-09-02T14:00:00.000Z'; + const reason = `waiting: preparing paused until ${localHhMm(until)}`; + await runTick(store, deps({ pausedUntil: () => until })); + + expect(prepared).toEqual([]); + expect(store.get('o/r#1')!.status).toBe('queued'); + expect(store.get('o/r#1')!.statusReason).toBe(reason); + expect(store.runs({})).toEqual([]); + + // The reconcile clears a queued row's reason every tick, so the pause has to put it back. + await runTick(store, deps({ pausedUntil: () => until })); + expect(store.get('o/r#1')!.statusReason).toBe(reason); + + await runTick(store, deps()); + expect(prepared).toEqual(['o/r#1']); + expect(store.get('o/r#1')!.status).toBe('prepared'); + }); + it('offers a bump on queued, skipped and failed rows only, and lists a bumped row first', async () => { forge.set(snapshot({ number: 1, additions: 10, deletions: 0 })); forge.set(snapshot({ number: 2, additions: 20, deletions: 0 })); diff --git a/packages/git/package.json b/packages/git/package.json index 104868f..f90e93a 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.10.22", + "version": "0.10.23", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/github/package.json b/packages/github/package.json index c6a4fc4..d68bf31 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.10.22", + "version": "0.10.23", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index a3fdae5..ddebcbf 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.10.22", + "version": "0.10.23", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 837d06c..6d2c18f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.10.22", + "version": "0.10.23", "type": "module", "private": true, "scripts": {