mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-09 22:13:39 +09:00
merge feat/wf-cost
This commit is contained in:
commit
e45ea295bc
8 files changed
+1838
No files matched your search
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The Workflows page's guards (ADR-462): two spend ceilings and the person's own alert rules, as plain options and one pure
|
||||
* evaluator. A guard only SAYS: it returns alerts for the notice ring and never stops, pauses or messages anything (the console has no
|
||||
* verb for that; Claude Code's Workflows panel and TaskStop do).
|
||||
*
|
||||
* Options (all optional; the defaults leave every guard off):
|
||||
* wfBudgetRunUsd number, 0 off, else 0.01 to 10000: one run's spend that raises a notice
|
||||
* wfBudgetDayUsd number, 0 off, else 0.01 to 10000: the spend of the workflow runs started in the last 24 hours
|
||||
* wfAlertRules text, words separated by spaces or commas: `stuck>20m` `tokens>2M` `dirty`
|
||||
*/
|
||||
import type { WfRun } from './workflows'
|
||||
|
||||
export type GuardOptions = { wfBudgetRunUsd: number; wfBudgetDayUsd: number; wfAlertRules: string }
|
||||
|
||||
export const GUARD_DEFAULTS: GuardOptions = { wfBudgetRunUsd: 0, wfBudgetDayUsd: 0, wfAlertRules: '' }
|
||||
|
||||
const usdOption = (value: unknown): number => {
|
||||
const amount = typeof value === 'number' ? value : typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : Number.NaN
|
||||
|
||||
return Number.isFinite(amount) && amount >= 0.01 && amount <= 10_000 ? amount : 0
|
||||
}
|
||||
|
||||
/** The options as the settings hold them, each one checked: a value that is not a dollar amount from 0.01 to 10000 is off. */
|
||||
export function guardOptionsOf(raw: unknown): GuardOptions {
|
||||
const value = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
wfBudgetRunUsd: usdOption(value.wfBudgetRunUsd),
|
||||
wfBudgetDayUsd: usdOption(value.wfBudgetDayUsd),
|
||||
// Control characters never reach a notice or a cell: the rules are parsed word by word and only a known shape survives.
|
||||
wfAlertRules: typeof value.wfAlertRules === 'string' ? value.wfAlertRules.slice(0, 200) : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** The guards in force. `null` is off. `problems` names each rule word that was not understood (it is shown, not dropped silently). */
|
||||
export type GuardRules = { stuckMin: number | null; tokens: number | null; isDirty: boolean; runUsd: number | null; dayUsd: number | null; problems: string[] }
|
||||
|
||||
export const NO_RULES: GuardRules = { stuckMin: null, tokens: null, isDirty: false, runUsd: null, dayUsd: null, problems: [] }
|
||||
|
||||
const TOKEN_UNIT: Record<string, number> = { '': 1, k: 1e3, m: 1e6, g: 1e9 }
|
||||
|
||||
/** `stuck>20m` `stuck>20` (minutes) `tokens>2M` `tokens>500000` `dirty`; the last of a kind wins. */
|
||||
export function parseGuardRules(options: Partial<GuardOptions>): GuardRules {
|
||||
const rules: GuardRules = { ...NO_RULES, problems: [], runUsd: usdOption(options.wfBudgetRunUsd) || null, dayUsd: usdOption(options.wfBudgetDayUsd) || null }
|
||||
|
||||
for (const word of (options.wfAlertRules ?? '').split(/[\s,;]+/).filter(Boolean).slice(0, 12)) {
|
||||
const stuck = /^stuck>(\d{1,4})m?$/i.exec(word)
|
||||
const tokens = /^tokens>(\d{1,10}(?:\.\d+)?)([kmg]?)$/i.exec(word)
|
||||
|
||||
if (/^dirty$/i.test(word)) rules.isDirty = true
|
||||
else if (stuck !== null && Number(stuck[1]) >= 1 && Number(stuck[1]) <= 1440) rules.stuckMin = Number(stuck[1])
|
||||
else if (tokens !== null && Number(tokens[1]) * (TOKEN_UNIT[(tokens[2] ?? '').toLowerCase()] ?? 1) >= 1000) rules.tokens = Math.round(Number(tokens[1]) * (TOKEN_UNIT[(tokens[2] ?? '').toLowerCase()] ?? 1))
|
||||
else rules.problems.push(`rule "${word.replace(/[^\w>.]/g, '?').slice(0, 20)}" not understood (stuck>20m, tokens>2M, dirty)`)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
/** A running agent's last sign of progress as the console saw it: what it showed, and since when it has shown only that. */
|
||||
export type Mark = { sig: string; sinceMs: number }
|
||||
|
||||
/** 52 bits of FNV-1a over two seeds, in base 36: a notice key is cut to 40 characters downstream, and two 17-character ids joined would not fit. */
|
||||
const hash = (value: string): string => {
|
||||
let a = 0x811c9dc5
|
||||
let b = 0x9747b28c
|
||||
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
a = Math.imul(a ^ value.charCodeAt(i), 0x01000193)
|
||||
b = Math.imul(b ^ value.charCodeAt(i), 0x85ebca6b)
|
||||
}
|
||||
|
||||
return `${(a >>> 0).toString(36)}${((b >>> 0) % 0x100000).toString(36)}`
|
||||
}
|
||||
|
||||
/** The key of one alert: short and stable, whatever the length of the ids it is about. */
|
||||
export const alertKey = (kind: 'budget-run' | 'tokens' | 'stuck' | 'dirty', runId: string, agentId?: string): string => `${kind}:${hash(agentId === undefined ? runId : `${runId}/${agentId}`)}`
|
||||
|
||||
/** The key of an agent across runs. */
|
||||
export const agentKey = (runId: string, agentId: string): string => `${runId}/${agentId}`
|
||||
|
||||
/**
|
||||
* Marks for the running agents of `runs`: an agent whose tokens, tool calls and last tool are unchanged since the last read keeps its
|
||||
* mark, a changed one starts a new mark at `nowMs`. The first sight of an agent starts at `nowMs`: the console cannot say how long
|
||||
* it was quiet before it looked.
|
||||
*/
|
||||
export function trackProgress(prev: ReadonlyMap<string, Mark>, runs: readonly WfRun[], nowMs: number): Map<string, Mark> {
|
||||
const next = new Map<string, Mark>()
|
||||
|
||||
for (const run of runs) {
|
||||
for (const phase of run.phases) {
|
||||
for (const agent of phase.agents) {
|
||||
if (agent.state !== 'running') continue
|
||||
|
||||
const key = agentKey(run.id, agent.id)
|
||||
const sig = `${agent.tokens ?? ''}|${agent.toolCalls ?? ''}|${agent.lastTool ?? ''}`
|
||||
const held = prev.get(key)
|
||||
|
||||
next.set(key, held !== undefined && held.sig === sig ? held : { sig, sinceMs: nowMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** What the guards decide from, beyond the runs. `dirty` is null where no reading was made (the rule is then silent, not false). */
|
||||
export type GuardInput = {
|
||||
runs: readonly WfRun[]
|
||||
rules: GuardRules
|
||||
marks: ReadonlyMap<string, Mark>
|
||||
dirty: ReadonlyMap<string, boolean> | null
|
||||
/** Dollars per run id from the cost read, with whether the figure is a floor. A run with no figure is absent. */
|
||||
spend: ReadonlyMap<string, { usd: number; isFloor: boolean }>
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
export type GuardAlert = { key: string; level: 'warn' | 'bad'; text: string; runId: string }
|
||||
|
||||
/** No sign of life for this long and the reader itself shows an agent as stale (data/workflows.ts STALE_MS). */
|
||||
const STALE_MIN = 15
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
|
||||
const clean = (value: string, max: number): string => value.replace(CONTROL, ' ').slice(0, max)
|
||||
const usd = (value: number): string => `$${value >= 100 ? Math.round(value) : value.toFixed(2)}`
|
||||
const tok = (value: number): string => (value >= 1e6 ? `${(value / 1e6).toFixed(1)}M` : value >= 1e3 ? `${Math.round(value / 1e3)}k` : String(value))
|
||||
|
||||
/** Every guard that is true now. Pure: the same input gives the same alerts, in run order. Text is for a notice: short, with no control characters. */
|
||||
export function evaluateGuards(input: GuardInput): GuardAlert[] {
|
||||
const { rules, nowMs } = input
|
||||
const out: GuardAlert[] = []
|
||||
let dayUsd = 0
|
||||
let dayFloor = false
|
||||
let dayRuns = 0
|
||||
|
||||
for (const run of input.runs) {
|
||||
if (run.kind !== 'workflow') continue
|
||||
|
||||
const name = clean(run.name, 30)
|
||||
const spend = input.spend.get(run.id)
|
||||
|
||||
if (rules.runUsd !== null && spend !== undefined && spend.usd >= rules.runUsd) {
|
||||
out.push({ key: alertKey('budget-run', run.id), level: 'warn', runId: run.id, text: `workflow ${name} spent ${spend.isFloor ? '≥' : ''}${usd(spend.usd)}, over your ${usd(rules.runUsd)} run ceiling (nothing was stopped)` })
|
||||
}
|
||||
|
||||
if (spend !== undefined && run.startedMs !== undefined && nowMs - run.startedMs <= DAY_MS) {
|
||||
dayUsd += spend.usd
|
||||
dayFloor ||= spend.isFloor
|
||||
dayRuns += 1
|
||||
}
|
||||
|
||||
if (rules.tokens !== null && run.totalTokens !== null && run.totalTokens >= rules.tokens) {
|
||||
out.push({ key: alertKey('tokens', run.id), level: 'warn', runId: run.id, text: `workflow ${name} is at ${run.isTokensPartial ? '≥' : ''}${tok(run.totalTokens)} tokens, over your ${tok(rules.tokens)} rule` })
|
||||
}
|
||||
|
||||
for (const phase of run.phases) {
|
||||
for (const agent of phase.agents) {
|
||||
const label = clean(agent.label, 30)
|
||||
const mark = input.marks.get(agentKey(run.id, agent.id))
|
||||
|
||||
if (rules.stuckMin !== null) {
|
||||
const quietMin = agent.state === 'stale' ? STALE_MIN : agent.state === 'running' && mark !== undefined ? (nowMs - mark.sinceMs) / 60_000 : 0
|
||||
|
||||
if (quietMin >= rules.stuckMin) out.push({ key: alertKey('stuck', run.id, agent.id), level: 'warn', runId: run.id, text: `agent ${label} in ${name}: no progress for ${agent.state === 'stale' ? '15m or more' : `${Math.floor(quietMin)}m`} (rule stuck>${rules.stuckMin}m)` })
|
||||
}
|
||||
|
||||
// A finished or failed agent whose worktree still holds uncommitted work: a running agent's is dirty by design.
|
||||
if (rules.isDirty && agent.hasWorktree && agent.state !== 'running' && agent.state !== 'queued' && input.dirty?.get(agentKey(run.id, agent.id)) === true) {
|
||||
out.push({ key: alertKey('dirty', run.id, agent.id), level: 'warn', runId: run.id, text: `agent ${label} in ${name} ended with an uncommitted worktree` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.dayUsd !== null && dayRuns > 0 && dayUsd >= rules.dayUsd) {
|
||||
out.push({ key: 'budget-day', level: 'warn', runId: '', text: `workflow runs of the last 24h spent ${dayFloor ? '≥' : ''}${usd(dayUsd)} over ${dayRuns} run${dayRuns === 1 ? '' : 's'}, over your ${usd(rules.dayUsd)} day ceiling (nothing was stopped)` })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge trigger: the alerts whose key was not raised before, and the keys now in force (a key that stopped being true is forgotten, so
|
||||
* the same trouble raises again if it returns). The notice ring's own 60 second dedupe is a second guard on top of this one.
|
||||
*/
|
||||
export function newAlerts(alerts: readonly GuardAlert[], raised: ReadonlySet<string>): { fresh: GuardAlert[]; raised: Set<string> } {
|
||||
return { fresh: alerts.filter(alert => !raised.has(alert.key)), raised: new Set(alerts.map(alert => alert.key).slice(0, 200)) }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* What a workflow run cost, by run, phase and agent (ADR-462). Pure: text and records in, records out; the disk and the
|
||||
* host are in `wf-cost-live.ts`.
|
||||
*
|
||||
* Two inputs, both real files: an agent's transcript, which says how many tokens each request used in each bucket, and the
|
||||
* ruflo-cost-tracker's price book (`data/prices.json`), the same file its ledger prices from. `WfAgent.tokens` is NOT used here:
|
||||
* it is the context size of the agent's latest request, which no price can be applied to. A model with no entry in the book is
|
||||
* shown as tokens and "no price", never as $0; a bucket the book does not publish is billed at the input rate and says so.
|
||||
* USD only: the Claude entries of the book are all USD, and an entry in any other unit is ignored rather than added in.
|
||||
*/
|
||||
import type { WfRun } from './workflows'
|
||||
|
||||
/** The buckets a request bills in (the ledger's names): `input` is uncached input only. */
|
||||
export type Usage = { input: number; cacheRead: number; write5m: number; write1h: number; output: number; messages: number; /** A cache write whose TTL split the transcript did not state: all billed as 5-minute writes. */ isUnsplit: boolean }
|
||||
|
||||
export const zeroUsage = (): Usage => ({ input: 0, cacheRead: 0, write5m: 0, write1h: 0, output: 0, messages: 0, isUnsplit: false })
|
||||
|
||||
export const tokensOf = (u: Usage): number => u.input + u.cacheRead + u.write5m + u.write1h + u.output
|
||||
|
||||
const addUsage = (to: Usage, from: Usage): void => {
|
||||
to.input += from.input
|
||||
to.cacheRead += from.cacheRead
|
||||
to.write5m += from.write5m
|
||||
to.write1h += from.write1h
|
||||
to.output += from.output
|
||||
to.messages += from.messages
|
||||
to.isUnsplit ||= from.isUnsplit
|
||||
}
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null)
|
||||
const n = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
|
||||
|
||||
/**
|
||||
* The usage in a transcript, per model. A request is written on several lines as it streams (the output count grows), so a
|
||||
* request is counted once by `message.id|requestId`, each bucket at its largest. `<synthetic>` and model-less lines are skipped.
|
||||
* `isTail` says the text begins mid-file: its first line is dropped, and the figures are a floor.
|
||||
*/
|
||||
export function usageOfTranscript(text: string | null, isTail = false): Map<string, Usage> {
|
||||
const out = new Map<string, Usage>()
|
||||
|
||||
if (text === null) return out
|
||||
|
||||
const body = isTail ? text.slice(text.indexOf('\n') + 1) : text
|
||||
const byRequest = new Map<string, { model: string; usage: Usage }>()
|
||||
|
||||
for (const line of body.split('\n')) {
|
||||
if (line.length < 2 || line[0] !== '{' || !line.includes('"usage"')) continue
|
||||
|
||||
let record: Record<string, unknown> | null = null
|
||||
|
||||
try {
|
||||
record = asRecord(JSON.parse(line))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const message = asRecord(record?.message)
|
||||
const usage = asRecord(message?.usage)
|
||||
const model = typeof message?.model === 'string' ? message.model.slice(0, 80) : ''
|
||||
|
||||
if (record === null || message === null || usage === null || message.role !== 'assistant' || model === '' || model === '<synthetic>') continue
|
||||
|
||||
const key = `${typeof message.id === 'string' ? message.id : String(record.uuid ?? byRequest.size)}|${typeof record.requestId === 'string' ? record.requestId : ''}`
|
||||
const split = asRecord(usage.cache_creation)
|
||||
const written = n(usage.cache_creation_input_tokens)
|
||||
const next: Usage = { input: n(usage.input_tokens), cacheRead: n(usage.cache_read_input_tokens), write5m: split === null ? written : n(split.ephemeral_5m_input_tokens), write1h: split === null ? 0 : n(split.ephemeral_1h_input_tokens), output: n(usage.output_tokens), messages: 1, isUnsplit: split === null && written > 0 }
|
||||
const held = byRequest.get(key)
|
||||
|
||||
if (held === undefined) byRequest.set(key, { model, usage: next })
|
||||
else {
|
||||
const u = held.usage
|
||||
|
||||
held.model = model
|
||||
u.input = Math.max(u.input, next.input)
|
||||
u.cacheRead = Math.max(u.cacheRead, next.cacheRead)
|
||||
u.write5m = Math.max(u.write5m, next.write5m)
|
||||
u.write1h = Math.max(u.write1h, next.write1h)
|
||||
u.output = Math.max(u.output, next.output)
|
||||
u.isUnsplit ||= next.isUnsplit
|
||||
}
|
||||
}
|
||||
|
||||
for (const { model, usage } of byRequest.values()) {
|
||||
const held = out.get(model) ?? zeroUsage()
|
||||
|
||||
addUsage(held, usage)
|
||||
out.set(model, held)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** One entry of the price book, in USD per million tokens. A rate the book does not publish is `null`. */
|
||||
export type Price = { id: string; match: string; re?: string; input: number; output: number; cacheRead: number | null; write5m: number | null; write1h: number | null; isApprox: boolean }
|
||||
export type PriceBook = { asOf: string; models: Price[] }
|
||||
|
||||
const MAX_MODELS = 120
|
||||
const rate = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null)
|
||||
|
||||
/** The Claude, USD entries of a `prices.json`; an entry that is malformed is skipped, and a file that is not a book is null. */
|
||||
export function parsePriceBook(text: string | null): PriceBook | null {
|
||||
let root: Record<string, unknown> | null = null
|
||||
|
||||
try {
|
||||
root = text === null ? null : asRecord(JSON.parse(text))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (root === null || !Array.isArray(root.models)) return null
|
||||
|
||||
const models: Price[] = []
|
||||
|
||||
for (const raw of root.models.slice(0, 400)) {
|
||||
const entry = asRecord(raw)
|
||||
const input = rate(entry?.input)
|
||||
const output = rate(entry?.output)
|
||||
|
||||
if (entry === null || entry.provider !== 'claude' || entry.unit !== 'usd' || typeof entry.id !== 'string' || typeof entry.match !== 'string' || entry.match === '' || input === null || output === null) continue
|
||||
|
||||
// A pattern from a file is compiled later; a long one is refused rather than risked.
|
||||
const re = typeof entry.re === 'string' && entry.re.length <= 120 ? entry.re : undefined
|
||||
|
||||
models.push({ id: entry.id.slice(0, 60), match: entry.match.slice(0, 60).toLowerCase(), input, output, cacheRead: rate(entry.cache_read), write5m: rate(entry.cache_write_5m), write1h: rate(entry.cache_write_1h), isApprox: entry.approx === true, ...(re !== undefined && { re }) })
|
||||
if (models.length >= MAX_MODELS) break
|
||||
}
|
||||
|
||||
return { asOf: typeof root.asOf === 'string' ? root.asOf.replace(/[^0-9A-Za-z:.-]/g, '').slice(0, 20) : 'unknown', models }
|
||||
}
|
||||
|
||||
/** The ledger's lookup: the entry whose `re` matches, else whose `match` is in the id; the longest `match` wins. */
|
||||
export function priceFor(book: PriceBook | null, model: string): Price | null {
|
||||
if (book === null || model === '') return null
|
||||
|
||||
const id = model.toLowerCase().slice(0, 80)
|
||||
let best: Price | null = null
|
||||
|
||||
for (const entry of book.models) {
|
||||
let isHit: boolean
|
||||
|
||||
try {
|
||||
isHit = entry.re === undefined ? id.includes(entry.match) || id === entry.id : new RegExp(entry.re).test(id)
|
||||
} catch {
|
||||
isHit = false
|
||||
}
|
||||
|
||||
if (isHit && (best === null || entry.match.length > best.match.length)) best = entry
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
const per = (tokens: number, rateUsd: number): number => (tokens / 1e6) * rateUsd
|
||||
|
||||
/** Dollars for one model's usage at one price; a cache rate the book does not publish is billed at the input rate (an upper bound, as the ledger does). */
|
||||
export function usdOf(usage: Usage, price: Price): number {
|
||||
const read = price.cacheRead ?? price.input
|
||||
const w5 = price.write5m ?? price.input
|
||||
const w1 = price.write1h ?? w5
|
||||
|
||||
return per(usage.input, price.input) + per(usage.output, price.output) + per(usage.cacheRead, read) + per(usage.write5m, w5) + per(usage.write1h, w1)
|
||||
}
|
||||
|
||||
/** A cost, with what it leaves out: tokens without a price are counted apart, never as $0. */
|
||||
export type Costed = {
|
||||
/** The priced part. */
|
||||
usd: number
|
||||
pricedTokens: number
|
||||
unpricedTokens: number
|
||||
unpricedModels: string[]
|
||||
/** Some price was a family fallback, or a cache rate was assumed: the figure is an estimate either way, this says it is a rougher one. */
|
||||
isApprox: boolean
|
||||
/** The figure is a floor: a transcript was read from its tail only, or an agent's was not read. */
|
||||
isFloor: boolean
|
||||
}
|
||||
|
||||
export const emptyCost = (): Costed => ({ usd: 0, pricedTokens: 0, unpricedTokens: 0, unpricedModels: [], isApprox: false, isFloor: false })
|
||||
|
||||
/** An agent's read usage: per model, and whether it is a tail. */
|
||||
export type AgentUsage = { byModel: ReadonlyMap<string, Usage>; isTail: boolean }
|
||||
|
||||
export function costOfUsage(agent: AgentUsage, book: PriceBook | null): Costed {
|
||||
const out = emptyCost()
|
||||
|
||||
out.isFloor = agent.isTail
|
||||
|
||||
for (const [model, usage] of agent.byModel) {
|
||||
const price = priceFor(book, model)
|
||||
const tokens = tokensOf(usage)
|
||||
|
||||
if (price === null) {
|
||||
out.unpricedTokens += tokens
|
||||
if (!out.unpricedModels.includes(model)) out.unpricedModels.push(model)
|
||||
} else {
|
||||
out.usd += usdOf(usage, price)
|
||||
out.pricedTokens += tokens
|
||||
out.isApprox ||= price.isApprox || price.cacheRead === null
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function sumCosts(parts: readonly Costed[], missing = 0): Costed {
|
||||
const out = emptyCost()
|
||||
|
||||
for (const part of parts) {
|
||||
out.usd += part.usd
|
||||
out.pricedTokens += part.pricedTokens
|
||||
out.unpricedTokens += part.unpricedTokens
|
||||
out.isApprox ||= part.isApprox
|
||||
out.isFloor ||= part.isFloor
|
||||
for (const model of part.unpricedModels) if (!out.unpricedModels.includes(model)) out.unpricedModels.push(model)
|
||||
}
|
||||
|
||||
out.isFloor ||= missing > 0
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export type RunCost = {
|
||||
runId: string
|
||||
/** The agents whose transcripts were read, by agent id. An agent not in the map has no figure: n/a, not $0. */
|
||||
agents: ReadonlyMap<string, Costed>
|
||||
phases: ReadonlyMap<string, Costed>
|
||||
total: Costed
|
||||
/** Agents of the run with a figure, and how many the run has. */
|
||||
covered: number
|
||||
count: number
|
||||
}
|
||||
|
||||
/** The run's cost from what was read. `usage` is keyed `<runId>/<agentId>`. */
|
||||
export function costRun(run: WfRun, usage: ReadonlyMap<string, AgentUsage>, book: PriceBook | null): RunCost {
|
||||
const agents = new Map<string, Costed>()
|
||||
const phases = new Map<string, Costed>()
|
||||
|
||||
for (const phase of run.phases) {
|
||||
const known: Costed[] = []
|
||||
|
||||
for (const agent of phase.agents) {
|
||||
const read = usage.get(`${run.id}/${agent.id}`)
|
||||
|
||||
if (read === undefined) continue
|
||||
|
||||
const cost = costOfUsage(read, book)
|
||||
|
||||
agents.set(agent.id, cost)
|
||||
known.push(cost)
|
||||
}
|
||||
|
||||
phases.set(phase.title, sumCosts(known, phase.agents.length - known.length))
|
||||
}
|
||||
|
||||
const total = sumCosts([...agents.values()], run.total - agents.size)
|
||||
|
||||
return { runId: run.id, agents, phases, total, covered: agents.size, count: run.total }
|
||||
}
|
||||
|
||||
/** $0.043 · $1.28 · $12.40 · <$0.001 */
|
||||
export function fmtUsd(usd: number): string {
|
||||
return usd <= 0 ? '$0' : usd < 0.001 ? '<$0.001' : usd < 0.1 ? `$${usd.toFixed(3)}` : usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
|
||||
}
|
||||
|
||||
/** A cost as a cell says it: `≥` for a floor, `≈` for a rough price, "no price" where tokens had none, "n/a" where nothing was read. */
|
||||
export function fmtCosted(cost: Costed | undefined): string {
|
||||
if (cost === undefined) return 'n/a'
|
||||
if (cost.pricedTokens === 0 && cost.unpricedTokens > 0) return 'no price'
|
||||
if (cost.pricedTokens === 0) return '$0'
|
||||
|
||||
return `${cost.isFloor || cost.unpricedTokens > 0 ? '≥' : cost.isApprox ? '≈' : ''}${fmtUsd(cost.usd)}${cost.unpricedTokens > 0 ? ' + unpriced' : ''}`
|
||||
}
|
||||
|
||||
/** Billed tokens of a cost: every bucket of every request, which is not the page's context-size figure. */
|
||||
export const billedTokens = (cost: Costed): number => cost.pricedTokens + cost.unpricedTokens
|
||||
|
||||
/** Spend per run for the guards: a run whose tokens were partly unpriced or unread is a floor. A run with no priced token has no figure. */
|
||||
export function spendOf(costs: Iterable<RunCost>): Map<string, { usd: number; isFloor: boolean }> {
|
||||
const out = new Map<string, { usd: number; isFloor: boolean }>()
|
||||
|
||||
for (const cost of costs) if (cost.total.pricedTokens > 0) out.set(cost.runId, { usd: cost.total.usd, isFloor: cost.total.isFloor || cost.total.unpricedTokens > 0 })
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Failure triage for a workflow run (ADR-462). Pure: an agent and, where the journal has it, the text of its result in; a class, the first
|
||||
* error line and a re-run text out.
|
||||
*
|
||||
* The classes are read from what Claude Code records and nothing else: the run record's state (`failed`), the journal's result event, the
|
||||
* agent's tool-call count and the reader's staleness. There is no timeout event in the files, so `timeout` is a textual match on the
|
||||
* result (the page says so). A result that merely MENTIONS an error is not an error: for an agent that finished, only a result that begins
|
||||
* with one is.
|
||||
*/
|
||||
import { maskSecrets, jsonLines, type WfAgent, type WfRun } from './workflows'
|
||||
|
||||
export type TriageKind = 'ok' | 'empty' | 'error' | 'timeout' | 'stale' | 'pending'
|
||||
|
||||
export type Triage = { kind: TriageKind; /** One short reason, for the row. */ why: string; /** The first error line of the result, masked and without control characters. */ firstError?: string }
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
|
||||
const tidy = (value: string, max: number): string => maskSecrets(value.replace(CONTROL, ' ').replace(/\s+/g, ' ').trim()).slice(0, max)
|
||||
|
||||
const ERROR_START = /^\W{0,3}(?:[A-Z][A-Za-z]*Error\s*[:(]|error\s*[:(]|fatal\s*[:(]|panic(?:ked)?\b|exception\s*[:(]|traceback \(most recent|npm ERR!|✖|✗)/i
|
||||
const ERROR_ANYWHERE = /\b(?:[A-Z][A-Za-z]*Error|ENOENT|EACCES|EPERM|ECONN[A-Z]+|ETIMEDOUT|ENOSPC)\b|\b(?:error|fatal|panic|failed|exception)\s*[:(]|\bTraceback\b|\bcould not\b|\bunable to\b|\bcannot\b/i
|
||||
const TIMEOUT = /time(?:d)?[\s-]?out|deadline exceeded|ETIMEDOUT|took too long/i
|
||||
const EMPTY = /^\W*(?:no (?:changes?|work|files?|findings?|issues?|results?|output|items?)(?: (?:made|needed|found|to (?:make|do|report)))?|nothing (?:to do|changed|found|to report|to change)|n\/a|none|null|undefined|\{\s*\}|\[\s*\])\W*$/i
|
||||
|
||||
/** The first line of `text` that reads as an error (any line), else null. Lines are split on newlines; a one-line preview is one line. */
|
||||
export function firstErrorLine(text: string, isAnywhere = true): string | null {
|
||||
for (const line of text.split('\n').slice(0, 200)) {
|
||||
if (ERROR_START.test(line.trim()) || (isAnywhere && ERROR_ANYWHERE.test(line))) {
|
||||
const clean = tidy(line, 140)
|
||||
|
||||
if (clean !== '') return clean
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** What the journal's result events say, by agent id: the text of each (a structured result is its `error` or `message` field, else its JSON), capped at 4000 characters. */
|
||||
export function resultsOfJournal(journal: string | null): Map<string, string> {
|
||||
const out = new Map<string, string>()
|
||||
|
||||
for (const event of jsonLines(journal)) {
|
||||
if (event.type !== 'result' || typeof event.agentId !== 'string') continue
|
||||
|
||||
const value = event.result
|
||||
const record = typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
|
||||
const text = typeof value === 'string' ? value : record !== null && typeof (record.error ?? record.message) === 'string' ? String(record.error ?? record.message) : JSON.stringify(value ?? '')
|
||||
|
||||
out.set(event.agentId.slice(0, 80), (text ?? '').slice(0, 4000))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** One agent's class. `result` is the journal's text for it, where there is one; without it the reader's 160-character preview is used. */
|
||||
export function classifyAgent(agent: WfAgent, result?: string): Triage {
|
||||
const text = result ?? agent.resultPreview ?? ''
|
||||
const head = text.trim()
|
||||
|
||||
if (agent.state === 'stale') return { kind: 'stale', why: 'no sign of life for 15 minutes or more' }
|
||||
if (agent.state === 'running' || agent.state === 'queued' || agent.state === 'idle') return { kind: 'pending', why: agent.state }
|
||||
|
||||
if (agent.state === 'failed') {
|
||||
const line = firstErrorLine(head) ?? (head === '' ? null : tidy(head.split('\n')[0] ?? '', 140))
|
||||
const kind: TriageKind = TIMEOUT.test(head.slice(0, 600)) ? 'timeout' : 'error'
|
||||
|
||||
return { kind, why: head === '' ? 'the run record marks it failed; no result text was recorded' : kind === 'timeout' ? 'its result reads as a timeout' : 'the run record marks it failed', ...(line !== null && line !== '' && { firstError: line }) }
|
||||
}
|
||||
|
||||
// Finished. A result that opens with an error is one; a result that only mentions one is not.
|
||||
const opening = firstErrorLine(head.split('\n').slice(0, 1).join('\n'), false)
|
||||
|
||||
if (opening !== null) return { kind: TIMEOUT.test(head.slice(0, 600)) ? 'timeout' : 'error', why: 'it finished, but its result opens with an error', firstError: opening }
|
||||
if (head !== '' && head.length <= 80 && EMPTY.test(head)) return { kind: 'empty', why: `its whole result is "${tidy(head, 40)}"` }
|
||||
if (head === '' && agent.toolCalls === 0) return { kind: 'empty', why: 'it finished with no result and no tool call' }
|
||||
|
||||
return { kind: 'ok', why: head === '' ? 'finished; no result text was recorded' : 'finished with a result' }
|
||||
}
|
||||
|
||||
export type TriageItem = { agent: WfAgent; triage: Triage; /** True where the journal holds a result event for the agent: a resume returns it cached instead of running it again. */ isCached: boolean }
|
||||
|
||||
export type TriageRun = { items: TriageItem[]; counts: Record<TriageKind, number> }
|
||||
|
||||
/** Every agent of the run, in phase order, with its class. `results` is null where the journal was not read. */
|
||||
export function triageRun(run: WfRun, results: ReadonlyMap<string, string> | null): TriageRun {
|
||||
const counts: Record<TriageKind, number> = { ok: 0, empty: 0, error: 0, timeout: 0, stale: 0, pending: 0 }
|
||||
const items: TriageItem[] = []
|
||||
|
||||
for (const phase of run.phases) {
|
||||
for (const agent of phase.agents) {
|
||||
const result = results?.get(agent.id)
|
||||
const triage = classifyAgent(agent, result)
|
||||
|
||||
counts[triage.kind] += 1
|
||||
items.push({ agent, triage, isCached: result !== undefined })
|
||||
}
|
||||
}
|
||||
|
||||
return { items, counts }
|
||||
}
|
||||
|
||||
export const failedOf = (triage: TriageRun): TriageItem[] => triage.items.filter(item => item.triage.kind === 'error' || item.triage.kind === 'timeout' || item.triage.kind === 'stale')
|
||||
|
||||
/** `2 failed · 1 empty · 1 stale · 9 ok · 3 running`; classes with none are left out, and a run with nothing to count says so. */
|
||||
export function triageStrip(counts: Record<TriageKind, number>): string {
|
||||
const failed = counts.error + counts.timeout
|
||||
const parts = [failed > 0 ? `${failed} failed` : '', counts.empty > 0 ? `${counts.empty} empty` : '', counts.stale > 0 ? `${counts.stale} stale` : '', counts.ok > 0 ? `${counts.ok} ok` : '', counts.pending > 0 ? `${counts.pending} running or waiting` : ''].filter(Boolean)
|
||||
|
||||
return parts.length === 0 ? 'no agents to triage' : parts.join(' · ')
|
||||
}
|
||||
|
||||
const RUN_ID = /^wf_[A-Za-z0-9-]{1,40}$/
|
||||
|
||||
/**
|
||||
* The text for resuming a run so only its unfinished calls run again, for the person to run: the console never runs it. The verb is the
|
||||
* Workflow tool's documented `resumeFromRunId` (ruflo-workflows: workflow-run): a call whose result is already in the journal returns
|
||||
* cached, so an agent that FAILED but left a result is not run again by it, and the text says which those are. Null for a ruflo swarm
|
||||
* (it has no such verb: its agents are stopped and spawned from the inspector) and for an id that is not a run id.
|
||||
*/
|
||||
export function rerunText(run: WfRun, failed: readonly TriageItem[], hasJournal: boolean): string[] | null {
|
||||
if (run.kind !== 'workflow' || !RUN_ID.test(run.id) || failed.length === 0) return null
|
||||
|
||||
const name = (item: TriageItem): string => `${tidy(item.agent.label, 40)} (${tidy(item.agent.phase, 30)})`
|
||||
const again = failed.filter(item => !hasJournal || !item.isCached)
|
||||
const cached = failed.filter(item => hasJournal && item.isCached)
|
||||
const list = (items: readonly TriageItem[]): string => `${items.slice(0, 10).map(name).join('; ')}${items.length > 10 ? `; +${items.length - 10} more` : ''}`
|
||||
|
||||
return [
|
||||
...(run.running > 0 ? ['Stop the run first (the console cannot: Claude Code\'s Workflows panel, or TaskStop), then:'] : []),
|
||||
`Workflow({ scriptPath: "<the script that started this run>", resumeFromRunId: "${run.id}" })`,
|
||||
...(again.length > 0 ? [`runs again, no result recorded: ${list(again)}`] : []),
|
||||
...(cached.length > 0 ? [`stays cached, its error is its recorded result: ${list(cached)}. Change that agent() call or its input to run it again`] : []),
|
||||
...(hasJournal ? [] : ['the journal was not read, so which of these are cached is unknown']),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Cost, triage and guards on the Workflows page (ADR-462), as three slots of views/wf-slots.ts: a Triage board section, a Cost board
|
||||
* section and a notice slot for the guards. Nothing here reads a disk: the figures come from the store `wf-cost-live.ts` fills, and a
|
||||
* section whose figures are not there says why instead of drawing a number. Cost is shown as `$` only where a price was found;
|
||||
* tokens without one read "no price". The console cannot stop or message a running workflow, so the page says where that is done.
|
||||
*/
|
||||
import type { RenderElement } from 'claude-code'
|
||||
|
||||
import { newAlerts } from '../data/wf-alerts'
|
||||
import { billedTokens, fmtCosted, fmtUsd } from '../data/wf-cost'
|
||||
import { failedOf, rerunText, resultsOfJournal, triageRun, triageStrip, type TriageItem } from '../data/wf-triage'
|
||||
import { fmtTokens, modelName, type WfRun } from '../data/workflows'
|
||||
import { under } from '../data/files'
|
||||
import { activeAlerts, BOOK_CAP, READ_BUDGET, guards } from '../wf-cost-live'
|
||||
import { TRANSCRIPT_CAP } from '../data/workflows-read'
|
||||
import type { NoticeDraft } from '../notices'
|
||||
import { clip, kv, text, THEME, type Ctx } from './common'
|
||||
import { registerSlot, type SlotEnv } from './wf-slots'
|
||||
|
||||
/** A long note as rows that fit the pane: the page's `text` clips to one line, and a sentence that matters must not end in an ellipsis. */
|
||||
function notes(ctx: Ctx, line: string, props: { color?: string; dimColor?: boolean } = { dimColor: true }, indent = ''): RenderElement[] {
|
||||
const width = Math.max(24, ctx.columns - 2 - indent.length)
|
||||
const out: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const word of line.split(' ')) {
|
||||
if (current !== '' && current.length + 1 + word.length > width) {
|
||||
out.push(current)
|
||||
current = word
|
||||
} else current = current === '' ? word : `${current} ${word}`
|
||||
}
|
||||
|
||||
if (current !== '') out.push(current)
|
||||
|
||||
return out.map((part, i) => text(ctx, `${indent}${i === 0 || indent === '' ? '' : ' '}${part}`, props))
|
||||
}
|
||||
|
||||
const mb = (bytes: number): string => `${(bytes / 1_000_000).toFixed(1)} MB`
|
||||
|
||||
/** The journal's results of a run, from the text the reader already holds (no read here); parsed once per text. */
|
||||
const memo = new Map<string, { text: string; results: Map<string, string> }>()
|
||||
|
||||
function resultsOf(ctx: Ctx, run: WfRun): ReadonlyMap<string, string> | null {
|
||||
if (run.kind !== 'workflow' || run.dir === undefined) return null
|
||||
|
||||
const path = under(run.dir, 'journal.jsonl')
|
||||
const held = ctx.state.cache.get(path)
|
||||
|
||||
if (held === undefined || !('text' in held)) return null
|
||||
|
||||
const known = memo.get(path)
|
||||
|
||||
if (known !== undefined && known.text === held.text) return known.results
|
||||
|
||||
const results = resultsOfJournal(held.text)
|
||||
|
||||
memo.delete(path)
|
||||
memo.set(path, { text: held.text, results })
|
||||
if (memo.size > 12) memo.delete(memo.keys().next().value as string)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
const KIND_MARK = { error: '✖', timeout: '⏱', stale: '◌', empty: '○', ok: '✔', pending: '…' } as const
|
||||
|
||||
function itemRow(ctx: Ctx, item: TriageItem): RenderElement {
|
||||
const { agent, triage } = item
|
||||
const tone = triage.kind === 'error' || triage.kind === 'timeout' ? THEME.bad : triage.kind === 'stale' ? THEME.warn : undefined
|
||||
const detail = triage.firstError ?? triage.why
|
||||
|
||||
return text(ctx, `${KIND_MARK[triage.kind]} ${clip(agent.label, 28)} · ${clip(agent.phase, 16)} · ${triage.kind} · ${detail}`, tone === undefined ? { dimColor: true } : { color: tone })
|
||||
}
|
||||
|
||||
/** The Triage section: the strip, the agents that need a look with the first error line, and the text to resume only the failed ones. */
|
||||
export function triageRows(env: SlotEnv): RenderElement[] {
|
||||
const { ctx, run } = env
|
||||
|
||||
if (run === null) return []
|
||||
|
||||
const results = resultsOf(ctx, run)
|
||||
const triage = triageRun(run, results)
|
||||
const failed = failedOf(triage)
|
||||
const empty = triage.items.filter(item => item.triage.kind === 'empty')
|
||||
const rows: RenderElement[] = [text(ctx, triageStrip(triage.counts), failed.length > 0 ? { color: THEME.bad, bold: true } : empty.length > 0 ? { color: THEME.warn } : { color: THEME.ok })]
|
||||
|
||||
for (const item of [...failed, ...empty].slice(0, 8)) rows.push(itemRow(ctx, item))
|
||||
|
||||
const more = failed.length + empty.length - 8
|
||||
|
||||
if (more > 0) rows.push(text(ctx, `+${more} more (j/k to them)`, { dimColor: true }))
|
||||
|
||||
const lines = rerunText(run, failed, results !== null)
|
||||
|
||||
if (lines !== null) {
|
||||
rows.push(...notes(ctx, 're-run just the failed items (this text is for you to run; the console does not):'))
|
||||
for (const line of lines) rows.push(...notes(ctx, line, { dimColor: true }, ' '))
|
||||
} else if (run.kind === 'ruflo-swarm' && failed.length > 0) {
|
||||
rows.push(text(ctx, 'A ruflo swarm has no resume verb: stop or spawn an agent from the inspector (confirm-gated).', { dimColor: true }))
|
||||
}
|
||||
|
||||
rows.push(...notes(ctx, run.kind === 'workflow' ? 'Classes come from the run record, the journal\'s results and the transcripts; no timeout event is written, so timeout is a text match.' : 'Classes come from the ruflo agent\'s status.'))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The ceilings and rules in force, in one line, or how to set them. */
|
||||
function guardLine(): string {
|
||||
const { rules } = guards
|
||||
const parts = [rules.runUsd !== null ? `run ${fmtUsd(rules.runUsd)}` : '', rules.dayUsd !== null ? `24h ${fmtUsd(rules.dayUsd)}` : '', rules.stuckMin !== null ? `stuck>${rules.stuckMin}m` : '', rules.tokens !== null ? `tokens>${fmtTokens(rules.tokens)}` : '', rules.isDirty ? 'dirty worktree' : ''].filter(Boolean)
|
||||
|
||||
return parts.length === 0 ? 'off: set wfBudgetRunUsd, wfBudgetDayUsd or wfAlertRules in the plugin options' : `${parts.join(' · ')} · a notice only: nothing is stopped`
|
||||
}
|
||||
|
||||
/** The Cost section: the run's spend, then each phase, then the agents of the phase under the cursor. */
|
||||
export function costRows(env: SlotEnv): RenderElement[] {
|
||||
const { ctx, run, phase } = env
|
||||
|
||||
if (run === null) return []
|
||||
if (run.kind !== 'workflow') return [text(ctx, 'A ruflo swarm records no model or tokens per agent, so it has no cost here.', { dimColor: true })]
|
||||
|
||||
const cost = guards.costs.get(run.id)
|
||||
const rows: RenderElement[] = []
|
||||
|
||||
rows.push(kv(ctx, 'prices', guards.book === null ? `none: ${guards.bookWhy}` : `ruflo-cost-tracker book as of ${guards.book.asOf} · USD list prices, estimates not bills (cap ${mb(BOOK_CAP)})`, guards.book === null ? THEME.warn : undefined))
|
||||
|
||||
if (cost === undefined) {
|
||||
rows.push(text(ctx, guards.error !== null ? `the last cost read failed (${clip(guards.error, 60)})` : 'cost is read after the run board, on each refresh while this page is open', { dimColor: true }))
|
||||
rows.push(kv(ctx, 'guards', guardLine()))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const { coverage } = guards
|
||||
|
||||
rows.push(kv(ctx, 'run', `${fmtCosted(cost.total)} · ${fmtTokens(billedTokens(cost.total), cost.total.isFloor)} billed tokens (all request buckets) · ${cost.covered} of ${cost.count} agents read`, cost.total.isFloor ? THEME.warn : undefined))
|
||||
rows.push(...notes(ctx, `Transcripts: up to ${mb(TRANSCRIPT_CAP)} each (a larger one is read from its end: ≥); at most ${mb(READ_BUDGET)} parsed per refresh; ${coverage.read} of ${coverage.total} read${coverage.left > 0 ? `, ${coverage.left} wait for the next refresh` : ''}. ≥ marks a floor, ≈ a family price.`))
|
||||
|
||||
if (cost.total.unpricedModels.length > 0) rows.push(text(ctx, `no price for ${cost.total.unpricedModels.slice(0, 3).map(model => modelName(model)).join(', ')}: its tokens are counted, its dollars are not`, { color: THEME.warn }))
|
||||
|
||||
for (const entry of run.phases.slice(0, 10)) {
|
||||
const mine = cost.phases.get(entry.title)
|
||||
const here = entry.title === phase?.title
|
||||
|
||||
rows.push(text(ctx, `${here ? '▸' : ' '} ${clip(entry.title, 24).padEnd(24)} ${fmtCosted(mine).padEnd(14)} ${mine === undefined ? 'n/a' : `${fmtTokens(billedTokens(mine), mine.isFloor)} tok`}`, here ? { bold: true } : { dimColor: true }))
|
||||
}
|
||||
|
||||
if (run.phases.length > 10) rows.push(text(ctx, `+${run.phases.length - 10} more phases`, { dimColor: true }))
|
||||
|
||||
if (phase !== null) {
|
||||
for (const agent of phase.agents.slice(0, 10)) {
|
||||
const mine = cost.agents.get(agent.id)
|
||||
rows.push(text(ctx, ` ${clip(agent.label, 26).padEnd(26)} ${modelName(agent.model).padEnd(11)} ${fmtCosted(mine).padEnd(12)} ${mine === undefined ? 'n/a' : `${fmtTokens(billedTokens(mine), mine.isFloor)} tok`}`, { dimColor: true }))
|
||||
}
|
||||
|
||||
if (phase.agents.length > 10) rows.push(text(ctx, ` +${phase.agents.length - 10} more agents`, { dimColor: true }))
|
||||
}
|
||||
|
||||
rows.push(kv(ctx, 'guards', guardLine()))
|
||||
// What is over a ceiling or a rule right now stays on the board, whether or not a notice was raised for it.
|
||||
for (const alert of activeAlerts(env.runs, ctx.nowMs).filter(entry => entry.runId === run.id || entry.runId === '').slice(0, 5)) rows.push(...notes(ctx, `now: ${alert.text}`, { color: THEME.warn }))
|
||||
for (const problem of guards.rules.problems) rows.push(text(ctx, problem, { color: THEME.warn }))
|
||||
if (guards.rules.isDirty && guards.dirty === null) rows.push(text(ctx, 'dirty: no worktree reading yet', { dimColor: true }))
|
||||
if (guards.rules.stuckMin !== null) rows.push(...notes(ctx, 'stuck is measured from when this console first saw the agent unchanged; an agent with no file activity for 15 minutes counts at once'))
|
||||
if (guards.rules.dayUsd !== null) rows.push(...notes(ctx, 'The 24h ceiling counts the workflow runs read on this page, not all your Claude spend (the Cost page has that).'))
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The guards' notices: edge-triggered (a condition raises once while it stays true, and what was already true at the first cost read is not announced), over the store the last cost read filled. One tick behind a read. */
|
||||
export function guardNotices(runs: readonly WfRun[], nowMs: number): NoticeDraft[] {
|
||||
const { fresh, raised } = newAlerts(activeAlerts(runs, nowMs), guards.raised)
|
||||
|
||||
guards.raised = raised
|
||||
|
||||
return fresh.map(alert => ({ level: alert.level, text: alert.text, key: alert.key, go: 'workflows' as const }))
|
||||
}
|
||||
|
||||
/** Puts the three slots on the page. Idempotent per registry: a second call is refused by the registry and does nothing. */
|
||||
export function registerWfTriage(): void {
|
||||
registerSlot({ kind: 'board', id: 'wf-triage', title: 'Triage', order: 10, render: triageRows })
|
||||
registerSlot({ kind: 'board', id: 'wf-cost', title: 'Cost', order: 20, render: costRows })
|
||||
registerSlot({ kind: 'notice', id: 'wf-guard', between: (_prev, next, nowMs) => guardNotices(next, nowMs) })
|
||||
}
|
||||
|
||||
registerWfTriage()
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The Workflows page's cost and guard readings (ADR-462), kept out of the controller and out of the views: a draw never reads a disk.
|
||||
* `refreshWfGuards` runs once per refresh tick right after `refreshWorkflows`, under the same gate (the page in front, the pane shown),
|
||||
* and fills one module-level store that the board slots and the notice slot (views/wf-triage.ts) read:
|
||||
* - the price book, from the ruflo-cost-tracker's `data/prices.json` (path from the validated install record, byte-capped);
|
||||
* - each agent's transcript usage, one bounded read apiece: a file over TRANSCRIPT_CAP is tail-read where the host can (a floor, marked),
|
||||
* a refresh reads at most READ_BUDGET bytes of files it has not parsed before, and the rest wait for the next refresh (shown as n/a);
|
||||
* - the progress marks for the stuck rule, and (only when the `dirty` rule is on) a read-only `git status` of at most 12 worktrees.
|
||||
* Nothing is written, nothing is stopped, and a failed read leaves the last figures with the reason.
|
||||
*/
|
||||
import { agentKey, evaluateGuards, NO_RULES, parseGuardRules, trackProgress, type GuardAlert, type GuardRules, type Mark } from './data/wf-alerts'
|
||||
import { costRun, parsePriceBook, spendOf, usageOfTranscript, type AgentUsage, type PriceBook, type RunCost } from './data/wf-cost'
|
||||
import { trackerOf } from './data/cost-ledger'
|
||||
import { readBounded, under } from './data/files'
|
||||
import { TAIL_BYTES, TRANSCRIPT_CAP, type WorkflowFs } from './data/workflows-read'
|
||||
import type { WfRun } from './data/workflows'
|
||||
import type { Host } from './host'
|
||||
import type { State } from './state'
|
||||
|
||||
/** The most of the price book that is read. */
|
||||
export const BOOK_CAP = 300_000
|
||||
/** The most bytes of transcripts parsed afresh in one refresh; a parsed file is remembered until it changes. */
|
||||
export const READ_BUDGET = 24_000_000
|
||||
export const MAX_DIRTY_PROBES = 12
|
||||
|
||||
export type Coverage = { read: number; total: number; bytes: number; /** Transcripts left for a later refresh or unreadable. */ left: number }
|
||||
|
||||
export type GuardStore = {
|
||||
book: PriceBook | null
|
||||
/** Why there is no book, in plain words; '' while there is one. */
|
||||
bookWhy: string
|
||||
costs: Map<string, RunCost>
|
||||
coverage: Coverage
|
||||
marks: Map<string, Mark>
|
||||
/** Worktree dirtiness by `<run>/<agent>`; null where the rule is off or nothing was read. */
|
||||
dirty: Map<string, boolean> | null
|
||||
rules: GuardRules
|
||||
/** The keys of the alerts raised and still true (views/wf-triage.ts: the notice slot). */
|
||||
raised: Set<string>
|
||||
readAtMs: number
|
||||
isReading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const fresh = (): GuardStore => ({ book: null, bookWhy: 'prices not read yet', costs: new Map(), coverage: { read: 0, total: 0, bytes: 0, left: 0 }, marks: new Map(), dirty: null, rules: NO_RULES, raised: new Set(), readAtMs: 0, isReading: false, error: null })
|
||||
|
||||
/** The store the slots read. One console, one store. */
|
||||
export let guards: GuardStore = fresh()
|
||||
|
||||
/** For tests: empties the store and the parse memory. */
|
||||
export function resetWfGuards(): void {
|
||||
guards = fresh()
|
||||
parsed.clear()
|
||||
}
|
||||
|
||||
type Parsed = { sig: string; usage: AgentUsage }
|
||||
/** What each transcript parsed to, by path, valid while its size and mtime are what they were. Bounded, oldest out. */
|
||||
const parsed = new Map<string, Parsed>()
|
||||
const PARSED_MAX = 800
|
||||
|
||||
/** True for a path under `root` with no `..` part and no control character. */
|
||||
export const isUnder = (path: string, root: string | null): boolean => root !== null && !/[\u0000-\u001f]/.test(path) && !path.split('/').includes('..') && path.startsWith(`${root.replace(/\/+$/, '')}/`)
|
||||
|
||||
const rememberParsed = (path: string, entry: Parsed): void => {
|
||||
parsed.delete(path)
|
||||
parsed.set(path, entry)
|
||||
if (parsed.size > PARSED_MAX) parsed.delete(parsed.keys().next().value as string)
|
||||
}
|
||||
|
||||
/** One agent's usage from its transcript, or null (with whether it was left for later) when it cannot be had now. */
|
||||
async function usageOf(fs: WorkflowFs, path: string, budget: { left: number; bytes: number }): Promise<{ usage: AgentUsage } | { later: boolean }> {
|
||||
const stat = await fs.stat(path).catch(() => undefined)
|
||||
|
||||
if (stat === undefined || typeof stat.size !== 'number') return { later: false }
|
||||
|
||||
const sig = `${stat.size}:${stat.mtimeMs ?? 0}`
|
||||
const held = parsed.get(path)
|
||||
|
||||
if (held !== undefined && held.sig === sig) return { usage: held.usage }
|
||||
|
||||
const isTail = stat.size > TRANSCRIPT_CAP
|
||||
const cost = isTail ? TAIL_BYTES : stat.size
|
||||
|
||||
if (cost > budget.left) return { later: true }
|
||||
if (isTail && fs.readTail === undefined) return { later: false }
|
||||
|
||||
// The bytes are spent before the read starts: files are read side by side, and the budget must hold across them.
|
||||
budget.left -= cost
|
||||
budget.bytes += cost
|
||||
|
||||
const text = await (isTail ? (fs.readTail as NonNullable<WorkflowFs['readTail']>)(path, TAIL_BYTES) : fs.read(path)).catch(() => null)
|
||||
|
||||
if (text === null) return { later: false }
|
||||
|
||||
const usage: AgentUsage = { byModel: usageOfTranscript(text, isTail), isTail }
|
||||
|
||||
rememberParsed(path, { sig, usage })
|
||||
|
||||
return { usage }
|
||||
}
|
||||
|
||||
/** Reads the price book of the cost tracker, or says why not. */
|
||||
async function readBook(state: State, host: Host): Promise<{ book: PriceBook | null; why: string }> {
|
||||
const tracker = trackerOf(state)
|
||||
|
||||
if (tracker.kind === 'absent') return { book: null, why: 'the ruflo-cost-tracker plugin is not installed, so tokens have no price' }
|
||||
if (tracker.kind === 'old') return { book: null, why: `ruflo-cost-tracker ${tracker.version} is too old to carry the price book` }
|
||||
|
||||
const read = await readBounded(host.fs, state.cache, under(tracker.root, 'data/prices.json'), BOOK_CAP)
|
||||
const book = parsePriceBook(read.text)
|
||||
|
||||
return book === null ? { book: null, why: read.text === null ? `the price book was not read (${read.reason})` : 'the price book is not in the expected shape' } : { book, why: '' }
|
||||
}
|
||||
|
||||
/** `git status` of one worktree, read-only: no optional locks, no fsmonitor program from the repository's config, nothing written. */
|
||||
async function isDirty(host: Host, path: string): Promise<boolean | undefined> {
|
||||
const result = await host.run(['git', '--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', path, 'status', '--porcelain=v1', '--untracked-files=no'], 5000).catch(() => null)
|
||||
|
||||
return result === null || result.exitCode !== 0 ? undefined : result.stdout.trim() !== ''
|
||||
}
|
||||
|
||||
/** Every guard that is true now over `runs`, from the store as the last cost read left it. Pure over the store; it reads nothing. */
|
||||
export const activeAlerts = (runs: readonly WfRun[], nowMs: number): GuardAlert[] => evaluateGuards({ runs, rules: guards.rules, marks: guards.marks, dirty: guards.dirty, spend: spendOf(guards.costs.values()), nowMs })
|
||||
|
||||
/** The runs to price, running ones first, then as read (newest first). */
|
||||
const priority = (runs: readonly WfRun[]): WfRun[] => [...runs.filter(run => run.kind === 'workflow' && run.running > 0), ...runs.filter(run => run.kind === 'workflow' && run.running === 0)]
|
||||
|
||||
/**
|
||||
* Reads the cost and guard figures for the runs `refreshWorkflows` last read. Same gate as that read; one at a time; resolves when done
|
||||
* or not needed. `force` skips the pane check (the open of the page). It never throws: a failure is `guards.error`.
|
||||
*/
|
||||
export async function refreshWfGuards(state: State, host: Host, force = false, nowMs: number = Date.now()): Promise<void> {
|
||||
if (state.view !== 'workflows' || guards.isReading || state.wf.read === null) return
|
||||
if (!force && state.isInteractive && !(state.pane.isOpen && state.pane.isShown)) return
|
||||
|
||||
const store = guards
|
||||
const isFirst = store.readAtMs === 0
|
||||
|
||||
store.isReading = true
|
||||
|
||||
try {
|
||||
const runs = priority(state.wf.read.runs)
|
||||
const options = state.options as unknown as { wfBudgetRunUsd?: number; wfBudgetDayUsd?: number; wfAlertRules?: string }
|
||||
const rules = parseGuardRules(options)
|
||||
const fs = host.fs as WorkflowFs
|
||||
const { book, why } = await readBook(state, host)
|
||||
const budget = { left: READ_BUDGET, bytes: 0 }
|
||||
const usage = new Map<string, AgentUsage>()
|
||||
let total = 0
|
||||
let left = 0
|
||||
|
||||
for (const run of runs) {
|
||||
await Promise.all(
|
||||
run.phases.flatMap(phase => phase.agents).map(async agent => {
|
||||
total += 1
|
||||
|
||||
if (agent.transcriptPath === undefined || !isUnder(agent.transcriptPath, state.configDir === null ? null : `${state.configDir.replace(/\/+$/, '')}/projects`)) return
|
||||
|
||||
const got = await usageOf(fs, agent.transcriptPath, budget)
|
||||
|
||||
if ('usage' in got) usage.set(agentKey(run.id, agent.id), got.usage)
|
||||
else if (got.later) left += 1
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let dirty: Map<string, boolean> | null = null
|
||||
|
||||
if (rules.isDirty) {
|
||||
dirty = new Map()
|
||||
|
||||
const candidates = runs.flatMap(run => run.phases.flatMap(phase => phase.agents.map(agent => ({ run, agent })))).filter(({ agent }) => agent.hasWorktree && agent.state !== 'running' && agent.state !== 'queued' && agent.worktreePath !== undefined && isUnder(agent.worktreePath, state.cwd)).slice(0, MAX_DIRTY_PROBES)
|
||||
|
||||
await Promise.all(
|
||||
candidates.map(async ({ run, agent }) => {
|
||||
const answer = await isDirty(host, agent.worktreePath as string)
|
||||
|
||||
if (answer !== undefined) (dirty as Map<string, boolean>).set(agentKey(run.id, agent.id), answer)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
store.book = book
|
||||
store.bookWhy = why
|
||||
store.costs = new Map(runs.map(run => [run.id, costRun(run, usage, book)]))
|
||||
store.coverage = { read: usage.size, total, bytes: budget.bytes, left }
|
||||
store.marks = trackProgress(store.marks, state.wf.read.runs, nowMs)
|
||||
store.dirty = dirty
|
||||
store.rules = rules
|
||||
store.readAtMs = nowMs
|
||||
store.error = null
|
||||
|
||||
// The first reading announces nothing, as the page's own notices do: what is already true when the console looks is on the Cost board, and only a crossing after it raises a notice.
|
||||
if (isFirst) store.raised = new Set(activeAlerts(state.wf.read.runs, nowMs).map(alert => alert.key))
|
||||
} catch (error) {
|
||||
store.error = error instanceof Error ? error.message.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 100) : 'the read was refused'
|
||||
} finally {
|
||||
store.isReading = false
|
||||
host.invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Workflows cost and guards (ADR-462), the pure half: transcript usage, the price book and its lookup, what a run costs by phase and
|
||||
* agent, the options and rules, and the guard evaluator. Fast and disk-free. Run with
|
||||
* npx vitest run plugins/ruflo-console/tests/wf-cost.spec.ts --testTimeout=30000
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { alertKey, evaluateGuards, guardOptionsOf, newAlerts, parseGuardRules, trackProgress, type GuardInput, type GuardRules, NO_RULES } from '../hooks/data/wf-alerts'
|
||||
import { billedTokens, costOfUsage, costRun, fmtCosted, fmtUsd, parsePriceBook, priceFor, spendOf, usageOfTranscript, usdOf, zeroUsage, type AgentUsage, type PriceBook } from '../hooks/data/wf-cost'
|
||||
import { buildRun, type WfRun } from '../hooks/data/workflows'
|
||||
import { journal, meta, result, started, T0, transcript } from './fixtures/workflows'
|
||||
|
||||
const BOOK_TEXT = JSON.stringify({
|
||||
asOf: '2026-10-03',
|
||||
models: [
|
||||
{ id: 'claude-opus-5-5', provider: 'claude', match: 'opus-5', unit: 'usd', input: 4, output: 20, cache_write_5m: 5, cache_write_1h: 8, cache_read: 0.2 },
|
||||
{ id: 'claude-sonnet-5-5', provider: 'claude', match: 'sonnet-5', unit: 'usd', input: 2, output: 10, cache_write_5m: 2.5, cache_write_1h: 4, cache_read: 0.2 },
|
||||
{ id: 'claude-opus-legacy', provider: 'claude', match: 'opus', unit: 'usd', input: 15, output: 75, cache_write_5m: 18.75, cache_write_1h: 30, cache_read: 1.5, approx: true },
|
||||
{ id: 'claude-haiku-nocache', provider: 'claude', match: 'haiku', unit: 'usd', input: 1, output: 5 },
|
||||
{ id: 'gpt-5', provider: 'codex', match: 'gpt-5', unit: 'usd', input: 1, output: 1 },
|
||||
{ id: 'gpt-6-sol', provider: 'claude', match: 'gpt-6', unit: 'credits', input: 50, output: 250 },
|
||||
{ id: 'broken', provider: 'claude', match: 'x', unit: 'usd', input: 'a', output: 1 },
|
||||
],
|
||||
})
|
||||
const BOOK = parsePriceBook(BOOK_TEXT) as PriceBook
|
||||
|
||||
const line = (over: { id?: string; model?: string; role?: string; usage: Record<string, unknown>; requestId?: string }): string =>
|
||||
JSON.stringify({ type: 'assistant', timestamp: new Date(T0).toISOString(), requestId: over.requestId ?? 'r1', message: { id: over.id ?? 'm1', model: over.model ?? 'claude-sonnet-5-5', role: over.role ?? 'assistant', content: [], usage: over.usage } })
|
||||
|
||||
describe('usageOfTranscript', () => {
|
||||
it('counts a streamed request once, each bucket at its largest, and sums distinct requests', () => {
|
||||
const usage = usageOfTranscript(transcript([{ at: 1, id: 'm1', input: 10, write: 100, read: 1000, output: 50 }, { at: 2, id: 'm2', input: 20, write: 0, read: 2000, output: 70 }]))
|
||||
const sonnet = usage.get('claude-sonnet-5-5')
|
||||
|
||||
// The fixture streams each request on two lines, the first with output 3: the real count (50, 70) wins, not the first line's.
|
||||
expect(sonnet).toMatchObject({ input: 30, cacheRead: 3000, write5m: 100, write1h: 0, output: 120, messages: 2 })
|
||||
expect(billedTokens({ usd: 0, pricedTokens: 3250, unpricedTokens: 0, unpricedModels: [], isApprox: false, isFloor: false })).toBe(3250)
|
||||
})
|
||||
|
||||
it('splits cache writes by TTL where the transcript says, and marks them unsplit where it does not', () => {
|
||||
const split = usageOfTranscript(line({ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 300, cache_creation: { ephemeral_5m_input_tokens: 100, ephemeral_1h_input_tokens: 200 } } }))
|
||||
const bare = usageOfTranscript(line({ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 300 } }))
|
||||
|
||||
expect(split.get('claude-sonnet-5-5')).toMatchObject({ write5m: 100, write1h: 200, isUnsplit: false })
|
||||
expect(bare.get('claude-sonnet-5-5')).toMatchObject({ write5m: 300, write1h: 0, isUnsplit: true })
|
||||
})
|
||||
|
||||
it('skips synthetic, model-less, non-assistant and half-written lines, and keeps models apart', () => {
|
||||
const text = [line({ model: '<synthetic>', usage: { input_tokens: 99 } }), line({ role: 'user', id: 'u', usage: { input_tokens: 99 } }), '{"half":', line({ id: 'a', model: 'claude-opus-5-5', usage: { input_tokens: 5 } }), line({ id: 'b', usage: { input_tokens: 7 } })].join('\n')
|
||||
const usage = usageOfTranscript(text)
|
||||
|
||||
expect([...usage.keys()].sort()).toEqual(['claude-opus-5-5', 'claude-sonnet-5-5'])
|
||||
expect(usage.get('claude-opus-5-5')?.input).toBe(5)
|
||||
expect(usageOfTranscript(null).size).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the first, probably cut, line of a tail', () => {
|
||||
const text = `${line({ id: 'cut', usage: { input_tokens: 1000 } })}\n${line({ id: 'kept', usage: { input_tokens: 5 } })}\n`
|
||||
|
||||
expect(usageOfTranscript(text, true).get('claude-sonnet-5-5')?.input).toBe(5)
|
||||
expect(usageOfTranscript(text, false).get('claude-sonnet-5-5')?.input).toBe(1005)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the price book', () => {
|
||||
it('keeps the well-formed Claude USD entries and nothing else', () => {
|
||||
expect(BOOK.asOf).toBe('2026-10-03')
|
||||
expect(BOOK.models.map(model => model.id)).toEqual(['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-opus-legacy', 'claude-haiku-nocache'])
|
||||
expect(parsePriceBook('not json')).toBeNull()
|
||||
expect(parsePriceBook('{"models":3}')).toBeNull()
|
||||
expect(parsePriceBook(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the most specific entry, as the ledger does', () => {
|
||||
expect(priceFor(BOOK, 'claude-opus-5-5')?.id).toBe('claude-opus-5-5')
|
||||
expect(priceFor(BOOK, 'claude-opus-4-1')?.id).toBe('claude-opus-legacy')
|
||||
expect(priceFor(BOOK, 'claude-opus-4-1')?.isApprox).toBe(true)
|
||||
expect(priceFor(BOOK, 'claude-mystery-9')).toBeNull()
|
||||
expect(priceFor(null, 'claude-opus-5-5')).toBeNull()
|
||||
expect(priceFor(BOOK, '')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a pattern that does not compile as no match instead of throwing', () => {
|
||||
const odd = parsePriceBook(JSON.stringify({ models: [{ id: 'a', provider: 'claude', match: 'zzz', re: '(', unit: 'usd', input: 1, output: 1 }] })) as PriceBook
|
||||
|
||||
expect(priceFor(odd, 'zzz-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what tokens cost', () => {
|
||||
const million = (over: Partial<ReturnType<typeof zeroUsage>>) => ({ ...zeroUsage(), ...over })
|
||||
|
||||
it('prices each bucket at its own rate (sonnet-5: 2 / 10 / 0.2 / 2.5 / 4 per million)', () => {
|
||||
const price = priceFor(BOOK, 'claude-sonnet-5-5') as NonNullable<ReturnType<typeof priceFor>>
|
||||
|
||||
expect(usdOf(million({ input: 1e6 }), price)).toBeCloseTo(2, 9)
|
||||
expect(usdOf(million({ output: 1e6 }), price)).toBeCloseTo(10, 9)
|
||||
expect(usdOf(million({ cacheRead: 1e6 }), price)).toBeCloseTo(0.2, 9)
|
||||
expect(usdOf(million({ write5m: 1e6 }), price)).toBeCloseTo(2.5, 9)
|
||||
expect(usdOf(million({ write1h: 1e6 }), price)).toBeCloseTo(4, 9)
|
||||
})
|
||||
|
||||
it('bills an unpublished cache rate at the input rate and calls the figure approximate', () => {
|
||||
const haiku = priceFor(BOOK, 'claude-haiku-4-5') as NonNullable<ReturnType<typeof priceFor>>
|
||||
|
||||
expect(usdOf(million({ cacheRead: 1e6, write5m: 1e6, write1h: 1e6 }), haiku)).toBeCloseTo(3, 9)
|
||||
expect(costOfUsage({ byModel: new Map([['claude-haiku-4-5', million({ cacheRead: 1e6 })]]), isTail: false }, BOOK).isApprox).toBe(true)
|
||||
})
|
||||
|
||||
it('never turns a model with no price into $0: its tokens are counted apart and the cell says no price', () => {
|
||||
const cost = costOfUsage({ byModel: new Map([['claude-mystery-9', million({ input: 500, output: 500 })]]), isTail: false }, BOOK)
|
||||
|
||||
expect(cost).toMatchObject({ usd: 0, pricedTokens: 0, unpricedTokens: 1000, unpricedModels: ['claude-mystery-9'] })
|
||||
expect(fmtCosted(cost)).toBe('no price')
|
||||
expect(fmtCosted(undefined)).toBe('n/a')
|
||||
// With no book at all (the tracker is absent) every model is unpriced.
|
||||
expect(fmtCosted(costOfUsage({ byModel: new Map([['claude-sonnet-5-5', million({ input: 5 })]]), isTail: false }, null))).toBe('no price')
|
||||
})
|
||||
|
||||
it('marks a floor (tail read or unpriced company) and an approximate family price', () => {
|
||||
const mixed = costOfUsage({ byModel: new Map([['claude-sonnet-5-5', million({ output: 1e6 })], ['claude-mystery-9', million({ input: 1 })]]), isTail: false }, BOOK)
|
||||
const tail = costOfUsage({ byModel: new Map([['claude-sonnet-5-5', million({ output: 1e6 })]]), isTail: true }, BOOK)
|
||||
const family = costOfUsage({ byModel: new Map([['claude-opus-4-1', million({ output: 1e6 })]]), isTail: false }, BOOK)
|
||||
|
||||
expect(fmtCosted(mixed)).toBe('≥$10.00 + unpriced')
|
||||
expect(fmtCosted(tail)).toBe('≥$10.00')
|
||||
expect(fmtCosted(family)).toBe('≈$75.00')
|
||||
})
|
||||
|
||||
it('formats dollars at their boundaries', () => {
|
||||
expect([0, 0.0004, 0.001, 0.0999, 0.1, 99.994, 100, 1234.5].map(fmtUsd)).toEqual(['$0', '<$0.001', '$0.001', '$0.100', '$0.10', '$99.99', '$100', '$1235'])
|
||||
})
|
||||
})
|
||||
|
||||
const run = (id = 'wf_c1'): WfRun =>
|
||||
buildRun({
|
||||
id,
|
||||
journal: journal(started('a1', 'build:a', 'Build'), started('a2', 'build:b', 'Build'), started('a3', 'rev:a', 'Review'), result('a1', 'ok')),
|
||||
agents: new Map(['a1', 'a2', 'a3'].map(agent => [agent, { meta: meta(agent, agent === 'a3' ? 'Review' : 'Build'), transcript: null, isTail: false, path: `/p/${agent}.jsonl` }])),
|
||||
record: null,
|
||||
script: null,
|
||||
nowMs: T0 + 5000,
|
||||
lastActivityMs: T0,
|
||||
})
|
||||
|
||||
const usageOf = (model: string, over: Partial<ReturnType<typeof zeroUsage>>): AgentUsage => ({ byModel: new Map([[model, { ...zeroUsage(), ...over }]]), isTail: false })
|
||||
|
||||
describe('costRun', () => {
|
||||
it('rolls agents into phases and the run, and shows an agent that was not read as n/a with the totals as floors', () => {
|
||||
const usage = new Map([['wf_c1/a1', usageOf('claude-sonnet-5-5', { output: 1e6 })], ['wf_c1/a3', usageOf('claude-opus-5-5', { output: 1e6 })]])
|
||||
const cost = costRun(run(), usage, BOOK)
|
||||
|
||||
expect(cost.agents.get('a1')?.usd).toBeCloseTo(10, 9)
|
||||
expect(cost.agents.has('a2')).toBe(false)
|
||||
expect(cost.phases.get('Build')?.usd).toBeCloseTo(10, 9)
|
||||
expect(cost.phases.get('Build')?.isFloor).toBe(true)
|
||||
expect(cost.phases.get('Review')?.isFloor).toBe(false)
|
||||
expect(cost.total.usd).toBeCloseTo(30, 9)
|
||||
expect(cost).toMatchObject({ covered: 2, count: 3 })
|
||||
expect(fmtCosted(cost.total)).toBe('≥$30.00')
|
||||
})
|
||||
|
||||
it('spendOf gives a floor flag and leaves out a run with no priced token', () => {
|
||||
const priced = costRun(run('wf_c1'), new Map([['wf_c1/a1', usageOf('claude-sonnet-5-5', { output: 1e6 })]]), BOOK)
|
||||
const dark = costRun(run('wf_c2'), new Map([['wf_c2/a1', usageOf('claude-mystery-9', { output: 1e6 })]]), BOOK)
|
||||
const spend = spendOf([priced, dark])
|
||||
|
||||
expect([...spend.keys()]).toEqual(['wf_c1'])
|
||||
expect(spend.get('wf_c1')?.isFloor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the options and rules', () => {
|
||||
it('reads dollar options from 0.01 to 10000 and turns anything else off', () => {
|
||||
const of = (value: unknown) => guardOptionsOf({ wfBudgetRunUsd: value }).wfBudgetRunUsd
|
||||
|
||||
expect([of(0.01), of(0.009), of(10_000), of(10_001), of('5'), of('-1'), of('1e3'), of(Number.NaN), of(undefined), of(null)]).toEqual([0.01, 0, 10_000, 0, 5, 0, 0, 0, 0, 0])
|
||||
expect(guardOptionsOf(null)).toEqual({ wfBudgetRunUsd: 0, wfBudgetDayUsd: 0, wfAlertRules: '' })
|
||||
})
|
||||
|
||||
it('parses stuck, tokens and dirty, and names every word it did not understand', () => {
|
||||
const rules = parseGuardRules({ wfAlertRules: 'stuck>20m, tokens>2M dirty', wfBudgetRunUsd: 5, wfBudgetDayUsd: 0 })
|
||||
|
||||
expect(rules).toMatchObject({ stuckMin: 20, tokens: 2_000_000, isDirty: true, runUsd: 5, dayUsd: null, problems: [] })
|
||||
expect(parseGuardRules({ wfAlertRules: 'stuck>20 tokens>500k' })).toMatchObject({ stuckMin: 20, tokens: 500_000 })
|
||||
expect(parseGuardRules({}).stuckMin).toBeNull()
|
||||
})
|
||||
|
||||
it('holds the rule limits: stuck 1 to 1440 minutes, tokens at least 1000', () => {
|
||||
expect([1, 1440].map(m => parseGuardRules({ wfAlertRules: `stuck>${m}` }).stuckMin)).toEqual([1, 1440])
|
||||
expect([0, 1441].map(m => parseGuardRules({ wfAlertRules: `stuck>${m}` }).stuckMin)).toEqual([null, null])
|
||||
expect(parseGuardRules({ wfAlertRules: 'tokens>1000' }).tokens).toBe(1000)
|
||||
expect(parseGuardRules({ wfAlertRules: 'tokens>999' }).tokens).toBeNull()
|
||||
|
||||
const bad = parseGuardRules({ wfAlertRules: 'stuck>0 loud \u001b[31mdirty' })
|
||||
|
||||
expect(bad.problems).toHaveLength(3)
|
||||
expect(bad.problems.join('')).not.toMatch(/\u001b/)
|
||||
})
|
||||
})
|
||||
|
||||
const rules = (over: Partial<GuardRules>): GuardRules => ({ ...NO_RULES, problems: [], ...over })
|
||||
const live = (running = true): WfRun => {
|
||||
const base = run('wf_g1')
|
||||
const state = running ? 'running' : 'done'
|
||||
|
||||
return { ...base, startedMs: T0, totalTokens: 1_500_000, isTokensPartial: false, phases: base.phases.map(phase => ({ ...phase, agents: phase.agents.map(agent => ({ ...agent, state, hasWorktree: true })) })) }
|
||||
}
|
||||
const input = (over: Partial<GuardInput>): GuardInput => ({ runs: [live()], rules: rules({}), marks: new Map(), dirty: null, spend: new Map(), nowMs: T0 + 60_000, ...over })
|
||||
|
||||
describe('evaluateGuards', () => {
|
||||
it('raises the run ceiling at the ceiling and not below it, saying nothing was stopped', () => {
|
||||
const at = evaluateGuards(input({ rules: rules({ runUsd: 5 }), spend: new Map([['wf_g1', { usd: 5, isFloor: false }]]) }))
|
||||
const under = evaluateGuards(input({ rules: rules({ runUsd: 5 }), spend: new Map([['wf_g1', { usd: 4.99, isFloor: false }]]) }))
|
||||
|
||||
expect(at.map(alert => alert.key)).toEqual([alertKey('budget-run', 'wf_g1')])
|
||||
expect(at[0]?.text).toMatch(/\$5\.00.*\$5\.00 run ceiling \(nothing was stopped\)/)
|
||||
expect(under).toEqual([])
|
||||
})
|
||||
|
||||
it('counts only runs started in the last 24 hours towards the day ceiling', () => {
|
||||
const recent = live()
|
||||
const old = { ...live(), id: 'wf_old', startedMs: T0 - 86_400_000 - 1 }
|
||||
const spend = new Map([['wf_g1', { usd: 3, isFloor: false }], ['wf_old', { usd: 50, isFloor: false }]])
|
||||
const day = (usdCap: number) => evaluateGuards(input({ runs: [recent, old], rules: rules({ dayUsd: usdCap }), spend })).filter(alert => alert.key === 'budget-day')
|
||||
|
||||
expect(day(3)).toHaveLength(1)
|
||||
expect(day(3.01)).toHaveLength(0)
|
||||
// A run started exactly 24h ago is still inside the window.
|
||||
expect(evaluateGuards(input({ runs: [{ ...recent, startedMs: T0 + 60_000 - 86_400_000 }], rules: rules({ dayUsd: 3 }), spend })).some(alert => alert.key === 'budget-day')).toBe(true)
|
||||
})
|
||||
|
||||
it('raises the token rule at the threshold, with a floor sign for partial tokens', () => {
|
||||
expect(evaluateGuards(input({ rules: rules({ tokens: 1_500_000 }) })).map(alert => alert.key)).toEqual([alertKey('tokens', 'wf_g1')])
|
||||
expect(evaluateGuards(input({ rules: rules({ tokens: 1_500_001 }) }))).toEqual([])
|
||||
expect(evaluateGuards(input({ runs: [{ ...live(), isTokensPartial: true }], rules: rules({ tokens: 1000 }) }))[0]?.text).toContain('≥1.5M')
|
||||
})
|
||||
|
||||
it('raises stuck from a mark that has not moved for N minutes, and at once for a stale agent when N is within 15', () => {
|
||||
const marks = new Map([['wf_g1/a1', { sig: 's', sinceMs: T0 }]])
|
||||
const at = (nowMs: number, stuckMin: number) => evaluateGuards(input({ rules: rules({ stuckMin }), marks, nowMs })).map(alert => alert.key)
|
||||
|
||||
expect(at(T0 + 20 * 60_000, 20)).toEqual([alertKey('stuck', 'wf_g1', 'a1')])
|
||||
expect(at(T0 + 20 * 60_000 - 1, 20)).toEqual([])
|
||||
|
||||
const stale = { ...live(), phases: live().phases.map(phase => ({ ...phase, agents: phase.agents.map(agent => ({ ...agent, state: 'stale' as const })) })) }
|
||||
|
||||
expect(evaluateGuards(input({ runs: [stale], rules: rules({ stuckMin: 15 }) })).length).toBe(3)
|
||||
expect(evaluateGuards(input({ runs: [stale], rules: rules({ stuckMin: 16 }) }))).toEqual([])
|
||||
})
|
||||
|
||||
it('raises dirty only for an ended agent whose worktree was read dirty: no reading, a clean one, or a running agent is silent', () => {
|
||||
const ended = live(false)
|
||||
const dirty = new Map([['wf_g1/a1', true], ['wf_g1/a2', false]])
|
||||
const keys = (runs: WfRun[], map: ReadonlyMap<string, boolean> | null) => evaluateGuards(input({ runs, rules: rules({ isDirty: true }), dirty: map })).map(alert => alert.key)
|
||||
|
||||
expect(keys([ended], dirty)).toEqual([alertKey('dirty', 'wf_g1', 'a1')])
|
||||
expect(keys([ended], null)).toEqual([])
|
||||
expect(keys([live(true)], dirty)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a ruflo swarm and a run with no figure', () => {
|
||||
expect(evaluateGuards(input({ runs: [{ ...live(), kind: 'ruflo-swarm' }], rules: rules({ tokens: 1000, runUsd: 1 }), spend: new Map([['wf_g1', { usd: 9, isFloor: false }]]) }))).toEqual([])
|
||||
expect(evaluateGuards(input({ rules: rules({ runUsd: 1 }) }))).toEqual([])
|
||||
})
|
||||
|
||||
it('strips control characters from the names it puts in a notice', () => {
|
||||
const run = { ...live(), name: 'x\u001b[31mred' }
|
||||
const text = evaluateGuards(input({ runs: [run], rules: rules({ tokens: 1000 }) }))[0]?.text ?? ''
|
||||
|
||||
expect(text).not.toMatch(/\u001b/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('alert keys', () => {
|
||||
it('stay distinct for 17-character ids that differ only in the last character, and fit the 40 the notice ring keeps', () => {
|
||||
const run = 'wf_8a5f1ac7-b9a-4'
|
||||
const keys = [alertKey('stuck', run, 'a362131950d43961b'), alertKey('stuck', run, 'a362131950d43961c'), alertKey('dirty', run, 'a362131950d43961b'), alertKey('tokens', run), alertKey('budget-run', run)]
|
||||
|
||||
expect(new Set(keys.map(key => key.slice(0, 40))).size).toBe(5)
|
||||
expect(Math.max(...keys.map(key => key.length))).toBeLessThanOrEqual(30)
|
||||
expect(alertKey('stuck', run, 'a362131950d43961b')).toBe(keys[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackProgress and newAlerts', () => {
|
||||
it('keeps a mark while the agent shows the same figures, restarts it when they move, and forgets agents that stopped running', () => {
|
||||
const first = trackProgress(new Map(), [live()], T0)
|
||||
const same = trackProgress(first, [live()], T0 + 5000)
|
||||
const moved = trackProgress(first, [{ ...live(), phases: live().phases.map(phase => ({ ...phase, agents: phase.agents.map(agent => ({ ...agent, tokens: 9 })) })) }], T0 + 9000)
|
||||
|
||||
expect(same.get('wf_g1/a1')?.sinceMs).toBe(T0)
|
||||
expect(moved.get('wf_g1/a1')?.sinceMs).toBe(T0 + 9000)
|
||||
expect(trackProgress(first, [live(false)], T0 + 9000).size).toBe(0)
|
||||
})
|
||||
|
||||
it('raises a key once while it stays true, forgets it when it clears, and raises it again if it returns', () => {
|
||||
const alert = { key: 'k', level: 'warn' as const, text: 't', runId: 'r' }
|
||||
const one = newAlerts([alert], new Set())
|
||||
const again = newAlerts([alert], one.raised)
|
||||
const cleared = newAlerts([], again.raised)
|
||||
|
||||
expect(one.fresh).toHaveLength(1)
|
||||
expect(again.fresh).toHaveLength(0)
|
||||
expect(newAlerts([alert], cleared.raised).fresh).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Workflows triage, cost board and guards (ADR-462): the classes and the re-run text, then the whole path over an in-memory disk: a read of
|
||||
* the runs, the cost read after it (price book, transcripts, byte budget, dirty probe), the page the person sees, and the notices. Run with
|
||||
* npx vitest run plugins/ruflo-console/tests/wf-triage.spec.ts --testTimeout=30000
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { classifyAgent, failedOf, firstErrorLine, rerunText, resultsOfJournal, triageRun, triageStrip } from '../hooks/data/wf-triage'
|
||||
import { buildRun, type WfAgent, type WfRun } from '../hooks/data/workflows'
|
||||
import type { WorkflowFs } from '../hooks/data/workflows-read'
|
||||
import type { Host } from '../hooks/host'
|
||||
import { newState, type State } from '../hooks/state'
|
||||
import { isUnder, MAX_DIRTY_PROBES, READ_BUDGET, refreshWfGuards, resetWfGuards, guards } from '../hooks/wf-cost-live'
|
||||
import { refreshWorkflows } from '../hooks/wf-live'
|
||||
import { workflowsActions } from '../hooks/wf-actions'
|
||||
import type { Actions, Ctx, Kit } from '../hooks/views/common'
|
||||
import { registerWfTriage } from '../hooks/views/wf-triage'
|
||||
import { workflowsPage } from '../hooks/views/wf-page'
|
||||
import { resetSlots, slotsFor } from '../hooks/views/wf-slots'
|
||||
import { journal, meta, record, result, started, T0, transcript } from './fixtures/workflows'
|
||||
|
||||
const agent = (over: Partial<WfAgent>): WfAgent => ({ id: 'a', label: 'build:a', phase: 'Build', state: 'done', hasWorktree: false, ...over })
|
||||
|
||||
describe('classifyAgent', () => {
|
||||
it('reads a pending agent as pending and a silent one as stale', () => {
|
||||
expect(classifyAgent(agent({ state: 'running' })).kind).toBe('pending')
|
||||
expect(classifyAgent(agent({ state: 'queued' })).kind).toBe('pending')
|
||||
expect(classifyAgent(agent({ state: 'stale' })).kind).toBe('stale')
|
||||
})
|
||||
|
||||
it('reads a failed agent as an error with its first error line, or a timeout where the result says so', () => {
|
||||
const failed = classifyAgent(agent({ state: 'failed' }), 'working...\nTypeError: x is not a function\n at foo')
|
||||
|
||||
expect(failed).toMatchObject({ kind: 'error', firstError: 'TypeError: x is not a function' })
|
||||
expect(classifyAgent(agent({ state: 'failed' }), 'request timed out after 600s').kind).toBe('timeout')
|
||||
expect(classifyAgent(agent({ state: 'failed' }), 'ETIMEDOUT connecting').kind).toBe('timeout')
|
||||
expect(classifyAgent(agent({ state: 'failed' }))).toMatchObject({ kind: 'error', why: expect.stringContaining('no result text') })
|
||||
})
|
||||
|
||||
it('reads a finished agent as an error only if its result OPENS with one, not if it mentions one', () => {
|
||||
expect(classifyAgent(agent({}), 'Error: build failed\nmore').kind).toBe('error')
|
||||
expect(classifyAgent(agent({}), 'RangeError: bad\nmore').kind).toBe('error')
|
||||
expect(classifyAgent(agent({}), 'Error handling is missing in two places; I added it.').kind).toBe('ok')
|
||||
expect(classifyAgent(agent({}), 'I fixed an Error: it was in the parser').kind).toBe('ok')
|
||||
expect(classifyAgent(agent({}), 'Done. The TypeError: case is handled.').kind).toBe('ok')
|
||||
})
|
||||
|
||||
it('reads a finished agent with nothing to show as empty, never a long answer that happens to say so', () => {
|
||||
expect(classifyAgent(agent({}), 'No changes needed.').kind).toBe('empty')
|
||||
expect(classifyAgent(agent({}), 'nothing to do').kind).toBe('empty')
|
||||
expect(classifyAgent(agent({}), '{}').kind).toBe('empty')
|
||||
expect(classifyAgent(agent({ toolCalls: 0 }), '').kind).toBe('empty')
|
||||
expect(classifyAgent(agent({ toolCalls: 12 }), '').kind).toBe('ok')
|
||||
expect(classifyAgent(agent({}), '').kind).toBe('ok')
|
||||
expect(classifyAgent(agent({}), `No changes needed ${'x'.repeat(80)}`).kind).toBe('ok')
|
||||
})
|
||||
|
||||
it('masks a credential and strips control characters from the error line', () => {
|
||||
const line = firstErrorLine('Error: failed with token sk-abcdefghijklmnopqrstuvwxyz0123456789 \u001b[31mred')
|
||||
|
||||
expect(line).not.toMatch(/sk-abc|\u001b/)
|
||||
expect(line).toContain('‹masked›')
|
||||
})
|
||||
|
||||
it('uses the reader\'s preview where the journal was not read', () => {
|
||||
expect(classifyAgent(agent({ state: 'failed', resultPreview: 'Error: boom' })).firstError).toBe('Error: boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the journal and the strip', () => {
|
||||
it('reads each agent\'s result text, a structured one by its error field', () => {
|
||||
const results = resultsOfJournal(journal(started('a', 'x', 'P'), result('a', 'plain text'), result('b', { error: 'it broke', other: 1 }), result('c', { n: 1 })))
|
||||
|
||||
expect(results.get('a')).toBe('plain text')
|
||||
expect(results.get('b')).toBe('it broke')
|
||||
expect(results.get('c')).toBe('{"n":1}')
|
||||
expect(resultsOfJournal(null).size).toBe(0)
|
||||
})
|
||||
|
||||
it('counts failed, empty, stale, ok and pending and says so in one line', () => {
|
||||
expect(triageStrip({ ok: 9, empty: 1, error: 1, timeout: 1, stale: 1, pending: 3 })).toBe('2 failed · 1 empty · 1 stale · 9 ok · 3 running or waiting')
|
||||
expect(triageStrip({ ok: 4, empty: 0, error: 0, timeout: 0, stale: 0, pending: 0 })).toBe('4 ok')
|
||||
expect(triageStrip({ ok: 0, empty: 0, error: 0, timeout: 0, stale: 0, pending: 0 })).toBe('no agents to triage')
|
||||
})
|
||||
})
|
||||
|
||||
const finished = (): WfRun =>
|
||||
buildRun({
|
||||
id: 'wf_rec',
|
||||
journal: journal(started('ra1', 'build:a', 'Build'), started('ra2', 'build:b', 'Build'), started('ra3', 'review:a', 'Review'), result('ra1', 'No changes needed.'), result('ra2', 'Error: boom')),
|
||||
agents: new Map(),
|
||||
record: record({ workflowProgress: [{ type: 'workflow_agent', label: 'build:a', phaseTitle: 'Build', agentId: 'ra1', state: 'done' }, { type: 'workflow_agent', label: 'build:b', phaseTitle: 'Build', agentId: 'ra2', state: 'failed' }, { type: 'workflow_agent', label: 'review:a', phaseTitle: 'Review', agentId: 'ra3', state: 'failed' }] }),
|
||||
script: null,
|
||||
nowMs: T0 + 100_000,
|
||||
})
|
||||
|
||||
describe('rerunText', () => {
|
||||
it('names the documented resume verb and splits agents that run again from those whose error is cached', () => {
|
||||
const run = finished()
|
||||
const results = resultsOfJournal(journal(result('ra1', 'No changes needed.'), result('ra2', 'Error: boom')))
|
||||
const triage = triageRun(run, results)
|
||||
const text = rerunText(run, failedOf(triage), true) ?? []
|
||||
|
||||
expect(triage.counts).toMatchObject({ empty: 1, error: 2 })
|
||||
expect(text[0]).toBe('Workflow({ scriptPath: "<the script that started this run>", resumeFromRunId: "wf_rec" })')
|
||||
expect(text.join('\n')).toMatch(/runs again, no result recorded: review:a \(Review\)/)
|
||||
expect(text.join('\n')).toMatch(/stays cached.*build:b \(Build\)/)
|
||||
})
|
||||
|
||||
it('tells the person to stop a running run first (the console cannot), and says when the journal was not read', () => {
|
||||
const run = { ...finished(), running: 1 }
|
||||
const text = rerunText(run, failedOf(triageRun(run, null)), false) ?? []
|
||||
|
||||
expect(text[0]).toMatch(/Stop the run first \(the console cannot/)
|
||||
expect(text.join('\n')).toMatch(/journal was not read/)
|
||||
})
|
||||
|
||||
it('gives no text for a swarm, an odd run id, or nothing failed', () => {
|
||||
const run = finished()
|
||||
const failed = failedOf(triageRun(run, null))
|
||||
|
||||
expect(rerunText({ ...run, kind: 'ruflo-swarm' }, failed, true)).toBeNull()
|
||||
expect(rerunText({ ...run, id: 'wf_x"; echo pwned' }, failed, true)).toBeNull()
|
||||
expect(rerunText(run, [], true)).toBeNull()
|
||||
})
|
||||
|
||||
it('caps the list at ten agents and says how many more', () => {
|
||||
const run = finished()
|
||||
const many = Array.from({ length: 13 }, (_, i) => ({ agent: agent({ id: `x${i}`, label: `l${i}`, state: 'failed' }), triage: { kind: 'error' as const, why: 'w' }, isCached: false }))
|
||||
const text = (rerunText(run, many, true) ?? []).join('\n')
|
||||
|
||||
expect(text).toContain('+3 more')
|
||||
expect(text).not.toContain('l10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnder', () => {
|
||||
it('accepts a path inside the root and refuses traversal, control characters and other roots', () => {
|
||||
expect(isUnder('/c/projects/a/b.jsonl', '/c/projects')).toBe(true)
|
||||
expect(isUnder('/c/projects/../x', '/c/projects')).toBe(false)
|
||||
expect(isUnder('/c/projectsX/a', '/c/projects')).toBe(false)
|
||||
expect(isUnder('/c/projects/a\u0000b', '/c/projects')).toBe(false)
|
||||
expect(isUnder('/c/projects/a', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- the whole path, over an in-memory disk ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
const NOW = T0 + 100_000
|
||||
const CONFIG = '/home/u/.claude'
|
||||
const ROOT = `${CONFIG}/projects/-work-proj`
|
||||
const SESSION = '11111111-2222-3333-4444-555555555555'
|
||||
const RUN = `${ROOT}/${SESSION}/subagents/workflows/wf_rec`
|
||||
const TRACKER = '/opt/tracker'
|
||||
const BOOK = JSON.stringify({ asOf: '2026-10-03', models: [{ id: 'claude-opus-5-5', provider: 'claude', match: 'opus-5', unit: 'usd', input: 4, output: 20, cache_read: 0.2 }, { id: 'claude-sonnet-5-5', provider: 'claude', match: 'sonnet-5', unit: 'usd', input: 2, output: 10, cache_read: 0.2 }] })
|
||||
|
||||
type El = { kind: string; props: Record<string, unknown> }
|
||||
const kit = { Box: (props: Record<string, unknown>): El => ({ kind: 'Box', props }), Text: (props: Record<string, unknown>): El => ({ kind: 'Text', props }), Button: (props: Record<string, unknown>): El => ({ kind: 'Button', props }) } as unknown as Kit
|
||||
const flat = (node: unknown, out: El[] = []): El[] => {
|
||||
if (Array.isArray(node)) node.forEach(child => flat(child, out))
|
||||
else if (typeof node === 'object' && node !== null) {
|
||||
out.push(node as El)
|
||||
flat((node as El).props?.children, out)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
const words = (tree: unknown): string => flat(tree).filter(el => el.kind === 'Text' && typeof el.props.children === 'string').map(el => el.props.children as string).join('\n')
|
||||
|
||||
function memoryFs(files: Record<string, string>, sizes: Record<string, number> = {}): WorkflowFs {
|
||||
const under = (dir: string) => [...new Set(Object.keys(files).filter(path => path.startsWith(`${dir}/`)).map(path => path.slice(dir.length + 1).split('/')[0] as string))]
|
||||
|
||||
return {
|
||||
read: async path => files[path] ?? Promise.reject(new Error('ENOENT')),
|
||||
stat: async path => (files[path] !== undefined ? { mtimeMs: T0, size: sizes[path] ?? files[path].length, kind: 'file' } : Promise.reject(new Error('ENOENT'))),
|
||||
list: async dir => {
|
||||
const names = under(dir)
|
||||
|
||||
return names.length === 0 ? Promise.reject(new Error('ENOENT')) : names.map(name => ({ name, mtimeMs: T0, size: sizes[`${dir}/${name}`] ?? files[`${dir}/${name}`]?.length }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const opus = (id: string) => transcript([{ at: 1, id, model: 'claude-opus-5-5', input: 0, write: 0, read: 0, output: 1_000_000 }])
|
||||
const sonnet = (id: string) => transcript([{ at: 1, id, input: 0, write: 0, read: 0, output: 1_000_000 }])
|
||||
|
||||
type Over = { options?: Record<string, unknown>; fs?: (files: Record<string, string>) => WorkflowFs; installed?: boolean; omit?: string[] }
|
||||
|
||||
const world = (extra: Record<string, string> = {}, over: Over = {}) => {
|
||||
const files: Record<string, string> = {
|
||||
[`${ROOT}/${SESSION}/workflows/wf_rec.json`]: record(),
|
||||
[`${RUN}/journal.jsonl`]: journal(started('ra1', 'build:a', 'Build'), started('ra2', 'build:b', 'Build'), started('ra3', 'review:a', 'Review'), result('ra1', 'done well'), result('ra2', 'Error: token sk-abcdefghijklmnopqrstuvwxyz0123456789 broke \u001b[31m')),
|
||||
[`${RUN}/agent-ra1.meta.json`]: meta('build:a', 'Build'),
|
||||
[`${RUN}/agent-ra2.meta.json`]: meta('build:b', 'Build'),
|
||||
[`${RUN}/agent-ra3.meta.json`]: meta('review:a', 'Review'),
|
||||
[`${RUN}/agent-ra1.jsonl`]: sonnet('m1'),
|
||||
[`${RUN}/agent-ra3.jsonl`]: opus('m3'),
|
||||
[`${TRACKER}/data/prices.json`]: BOOK,
|
||||
...extra,
|
||||
}
|
||||
|
||||
for (const path of over.omit ?? []) delete files[path]
|
||||
|
||||
const state = newState({})
|
||||
const ran: string[][] = []
|
||||
const fs = over.fs?.(files) ?? memoryFs(files)
|
||||
const host = { fs, invalidate: () => undefined, run: async (argv: string[]) => (ran.push(argv), { exitCode: 0, stdout: ' M file.ts\n', stderr: '' }) } as unknown as Host
|
||||
|
||||
state.view = 'workflows'
|
||||
state.configDir = CONFIG
|
||||
state.cwd = '/work/proj'
|
||||
state.isInteractive = false
|
||||
Object.assign(state.options, over.options ?? {})
|
||||
if (over.installed !== false) state.snapshot = { plugins: { installed: [{ id: 'ruflo-cost-tracker@ruflo', name: 'ruflo-cost-tracker', marketplace: 'ruflo', version: '0.27.0', scope: 'user', installPath: TRACKER }] }, swarm: null, agents: [] } as unknown as State['snapshot']
|
||||
|
||||
return { state, host, files, ran }
|
||||
}
|
||||
|
||||
function pageText(state: State, host: Host, columns = 110): string {
|
||||
const runner = { ask: () => undefined }
|
||||
const act = { workflows: workflowsActions(state, host, runner as never) } as unknown as Actions
|
||||
const ctx: Ctx = { kit, state, nowMs: NOW, columns, pictures: new Map(), act }
|
||||
|
||||
return words(workflowsPage(ctx))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSlots()
|
||||
resetWfGuards()
|
||||
registerWfTriage()
|
||||
})
|
||||
|
||||
describe('the slots', () => {
|
||||
it('registers a Triage board, a Cost board and a guard notice slot, in that order', () => {
|
||||
expect(slotsFor('board').map(slot => slot.id)).toEqual(['wf-triage', 'wf-cost'])
|
||||
expect(slotsFor('notice').map(slot => slot.id)).toEqual(['wf-guard'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the cost read and the page', () => {
|
||||
it('prices a finished run by agent, phase and run from its transcripts and the tracker\'s book, with n/a for the agent that left none', async () => {
|
||||
const { state, host } = world()
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
|
||||
const cost = guards.costs.get('wf_rec')
|
||||
|
||||
expect(guards.book?.asOf).toBe('2026-10-03')
|
||||
expect(cost?.agents.get('ra1')?.usd).toBeCloseTo(10, 6)
|
||||
expect(cost?.agents.get('ra3')?.usd).toBeCloseTo(20, 6)
|
||||
expect(cost?.agents.has('ra2')).toBe(false)
|
||||
expect(cost?.total).toMatchObject({ isFloor: true })
|
||||
expect(cost?.total.usd).toBeCloseTo(30, 6)
|
||||
expect(guards.coverage).toMatchObject({ read: 2, total: 3, left: 0 })
|
||||
|
||||
const page = pageText(state, host)
|
||||
|
||||
expect(page).toContain('≥$30.00')
|
||||
expect(page).toContain('2 of 3 agents read')
|
||||
expect(page).toContain('Build')
|
||||
expect(page).toContain('≥$10.00')
|
||||
expect(page).toContain('billed tokens')
|
||||
expect(page).toContain('book as of 2026-10-03')
|
||||
})
|
||||
|
||||
it('says why there is no price where the cost tracker is not installed, and shows tokens, not dollars', async () => {
|
||||
const { state, host } = world({}, { installed: false })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
|
||||
expect(guards.book).toBeNull()
|
||||
expect(guards.bookWhy).toMatch(/not installed/)
|
||||
|
||||
const page = pageText(state, host)
|
||||
|
||||
expect(page).toMatch(/prices\s+none: the ruflo-cost-tracker plugin is not installed/)
|
||||
expect(page).toContain('no price')
|
||||
expect(page).not.toMatch(/\$\d/)
|
||||
})
|
||||
|
||||
it('says the cost is not read yet before the first cost read, instead of drawing a number', async () => {
|
||||
const { state, host } = world()
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
|
||||
expect(pageText(state, host)).toContain('cost is read after the run board')
|
||||
})
|
||||
|
||||
it('reads nothing while the page is not in front, or the pane is closed', async () => {
|
||||
const { state, host, ran } = world({}, { options: { wfAlertRules: 'dirty' } })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
state.view = 'overview'
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
expect(guards.readAtMs).toBe(0)
|
||||
|
||||
state.view = 'workflows'
|
||||
state.isInteractive = true
|
||||
state.pane.isOpen = true
|
||||
state.pane.isShown = false
|
||||
await refreshWfGuards(state, host, false, NOW)
|
||||
expect(guards.readAtMs).toBe(0)
|
||||
expect(ran).toEqual([])
|
||||
})
|
||||
|
||||
it('holds the byte budget: transcripts beyond it wait for the next refresh and are shown as left', async () => {
|
||||
const ids = Array.from({ length: 9 }, (_, i) => `r${i}`)
|
||||
const files: Record<string, string> = { [`${RUN}/journal.jsonl`]: journal(...ids.map(id => started(id, id, 'Build'))) }
|
||||
const sizes: Record<string, number> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
files[`${RUN}/agent-${id}.meta.json`] = meta(id, 'Build')
|
||||
files[`${RUN}/agent-${id}.jsonl`] = sonnet(`m-${id}`)
|
||||
sizes[`${RUN}/agent-${id}.jsonl`] = 2_900_000
|
||||
}
|
||||
|
||||
const { state, host } = world(files, { fs: all => memoryFs(all, sizes), omit: [`${ROOT}/${SESSION}/workflows/wf_rec.json`, `${RUN}/agent-ra1.meta.json`, `${RUN}/agent-ra2.meta.json`, `${RUN}/agent-ra3.meta.json`, `${RUN}/agent-ra1.jsonl`, `${RUN}/agent-ra3.jsonl`] })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
|
||||
expect(2_900_000 * 8).toBeLessThanOrEqual(READ_BUDGET)
|
||||
expect(2_900_000 * 9).toBeGreaterThan(READ_BUDGET)
|
||||
expect(guards.coverage).toMatchObject({ read: 8, total: 9, left: 1 })
|
||||
|
||||
// The parsed ones are remembered: the next refresh reads the one that waited.
|
||||
await refreshWfGuards(state, host, true, NOW + 3000)
|
||||
expect(guards.coverage).toMatchObject({ read: 9, left: 0 })
|
||||
})
|
||||
|
||||
it('reads an oversized transcript from its end and marks the cost a floor', async () => {
|
||||
const path = `${RUN}/agent-ra1.jsonl`
|
||||
const { state, host } = world({}, { fs: files => ({ ...memoryFs(files, { [path]: 4_000_000 }), readTail: async () => `cut-off line\n${sonnet('tail')}` }) })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
|
||||
expect(guards.costs.get('wf_rec')?.agents.get('ra1')).toMatchObject({ isFloor: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('triage on the page', () => {
|
||||
it('shows the strip, the first error line and the re-run text, with the credential masked and no escape sequence', async () => {
|
||||
const { state, host } = world()
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
|
||||
const page = pageText(state, host)
|
||||
|
||||
expect(page).toContain('1 failed')
|
||||
expect(page).toMatch(/build:b.*error.*Error: token/)
|
||||
expect(page).toContain('resumeFromRunId: "wf_rec"')
|
||||
expect(page).toMatch(/stays cached.*build:b/)
|
||||
expect(page).not.toMatch(/sk-abc|\u001b/)
|
||||
expect(page).toContain('timeout is a text match')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a narrow pane', () => {
|
||||
it('still draws both sections and never leaves a sentence cut off by an ellipsis in the notes', async () => {
|
||||
const { state, host } = world()
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
|
||||
const page = pageText(state, host, 50)
|
||||
|
||||
expect(page).toContain('1 failed')
|
||||
expect(page).toContain('Cost')
|
||||
expect(page).not.toMatch(/(stays cached|timeout is a text|up to 3\.0 MB)[^\n]*…/)
|
||||
expect(page).toContain('resumeFromRunId')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const guardNotes = (state: State) => state.notices.filter(notice => notice.key.startsWith('wf-guard:'))
|
||||
const RA3 = `${RUN}/agent-ra3.jsonl`
|
||||
|
||||
describe('guard notices', () => {
|
||||
it('announces nothing for what is already true at the first cost read, yet keeps it on the Cost board', async () => {
|
||||
const { state, host } = world({}, { options: { wfBudgetRunUsd: 25 } })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
await refreshWorkflows(state, host, true, NOW + 1000)
|
||||
|
||||
expect(guardNotes(state)).toHaveLength(0)
|
||||
expect(pageText(state, host)).toMatch(/now: workflow demo-run spent ≥\$30\.00, over your \$25\.00 run ceiling \(nothing was stopped\)/)
|
||||
})
|
||||
|
||||
it('raises the run ceiling once when a run CROSSES it, saying nothing was stopped, and not again while it stays over', async () => {
|
||||
const { state, host, files } = world({}, { options: { wfBudgetRunUsd: 25 }, omit: [RA3] })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
await refreshWorkflows(state, host, true, NOW + 1000)
|
||||
expect(guardNotes(state)).toHaveLength(0)
|
||||
expect(guards.costs.get('wf_rec')?.total.usd).toBeCloseTo(10, 6)
|
||||
|
||||
files[RA3] = opus('m3')
|
||||
await refreshWfGuards(state, host, true, NOW + 3000)
|
||||
await refreshWorkflows(state, host, true, NOW + 4000)
|
||||
|
||||
expect(guardNotes(state)).toHaveLength(1)
|
||||
expect(guardNotes(state)[0]).toMatchObject({ level: 'warn', go: 'workflows' })
|
||||
expect(guardNotes(state)[0]?.text).toMatch(/spent ≥\$30\.00, over your \$25\.00 run ceiling \(nothing was stopped\)/)
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW + 200_000)
|
||||
expect(guardNotes(state)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stays quiet at a ceiling above the spend', async () => {
|
||||
const { state, host, files } = world({}, { options: { wfBudgetRunUsd: 31 }, omit: [RA3] })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
files[RA3] = opus('m3')
|
||||
await refreshWfGuards(state, host, true, NOW + 3000)
|
||||
await refreshWorkflows(state, host, true, NOW + 4000)
|
||||
expect(guardNotes(state)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('probes worktrees read-only with a fixed argv, only inside the project, and raises dirty for an agent that ENDED dirty', async () => {
|
||||
const inside = '/work/proj/.claude/worktrees/a1'
|
||||
const { state, host, ran, files } = world({}, { options: { wfAlertRules: 'dirty' } })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
expect(ran).toEqual([])
|
||||
expect(guardNotes(state)).toHaveLength(0)
|
||||
|
||||
Object.assign(files, { [`${RUN}/agent-ra1.meta.json`]: meta('build:a', 'Build', { spawnedWithWorktree: true, worktreePath: inside }), [`${RUN}/agent-ra2.meta.json`]: meta('build:b', 'Build', { spawnedWithWorktree: true, worktreePath: '/elsewhere/wt' }), [`${RUN}/agent-ra3.meta.json`]: meta('review:a', 'Review', { spawnedWithWorktree: true, worktreePath: '/work/proj/../etc' }) })
|
||||
await refreshWorkflows(state, host, true, NOW + 3000)
|
||||
await refreshWfGuards(state, host, true, NOW + 3000)
|
||||
|
||||
expect(ran).toEqual([['git', '--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', inside, 'status', '--porcelain=v1', '--untracked-files=no']])
|
||||
expect(guards.dirty?.get('wf_rec/ra1')).toBe(true)
|
||||
expect(MAX_DIRTY_PROBES).toBe(12)
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW + 4000)
|
||||
expect(guardNotes(state).map(notice => notice.text).join('\n')).toMatch(/build:a.*uncommitted worktree/)
|
||||
})
|
||||
|
||||
it('does not run any git probe where the dirty rule is off', async () => {
|
||||
const { state, host, ran } = world({ [`${RUN}/agent-ra1.meta.json`]: meta('build:a', 'Build', { spawnedWithWorktree: true, worktreePath: '/work/proj/wt' }) })
|
||||
|
||||
await refreshWorkflows(state, host, true, NOW)
|
||||
await refreshWfGuards(state, host, true, NOW)
|
||||
expect(ran).toEqual([])
|
||||
expect(guards.dirty).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
# ADR 462: Workflows page: cost, failure triage and alert guards
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-10-05
|
||||
|
||||
Builds on: ADR-458 (the page's data), ADR-464 (wiring and slot seams), ADR-437 (the cost ledger), ADR-453 (confirm-gated verbs)
|
||||
|
||||
## 1. Context
|
||||
|
||||
The Workflows page shows what a run is doing but not what it costs, which of its agents came to nothing, or when something
|
||||
needs a person. `WfAgent.tokens` cannot answer the first: it is the context size of an agent's latest request, and a price needs
|
||||
the tokens of every request in each billing bucket (input, cache read, cache write 5m and 1h, output). Claude Code writes those
|
||||
per request in the agent's transcript; the ruflo-cost-tracker already prices them from a dated `data/prices.json`.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
All of it plugs in through the slot seams of `views/wf-slots.ts`; no shared file is edited.
|
||||
|
||||
**Cost** (`data/wf-cost.ts`, pure). Transcript usage is summed per model, each request counted once by `message.id|requestId` with each
|
||||
bucket at its largest (a streamed request repeats its line while the output count grows; the ledger keeps the first line, so on a
|
||||
streamed transcript this reads output slightly higher than the ledger and is the nearer to the bill). Prices come from the tracker's book,
|
||||
found through the validated install record, byte-capped (300 KB), Claude and USD entries only, looked up as the ledger does (a `re`
|
||||
wins, else the longest `match` in the id). A model with no entry is tokens and **"no price"**, never $0; a cache rate the book does not
|
||||
publish is billed at the input rate and the figure is marked `≈`; a transcript read only from its end, an unread agent, or unpriced
|
||||
company marks the figure a floor `≥`. Cost is shown per run, per phase and per agent, beside "billed tokens" (all buckets of all
|
||||
requests), which is a different figure from the page's context tokens and is labelled so.
|
||||
|
||||
**Reading** (`wf-cost-live.ts`, outside render and the controller). `refreshWfGuards(state, host, force?, nowMs?)` runs after
|
||||
`refreshWorkflows` under the same gate (page in front, pane shown, one read at a time). Per agent: stat, then one bounded read of the
|
||||
transcript at `transcriptPath` (checked to be under `<configDir>/projects/`, no `..`); over 3 MB it is tail-read where the host can
|
||||
(a floor), else left. At most 24 MB of not-yet-parsed transcript per refresh; a parsed file is remembered by size and mtime, so a
|
||||
finished run is parsed once and the rest wait for the next refresh (shown: "N of M read, K wait"). Nothing is written.
|
||||
|
||||
**Triage** (`data/wf-triage.ts`, pure). Each agent is `ok`, `empty`, `error`, `timeout`, `stale` (or `pending` while it runs), from the
|
||||
run record's state, the journal's result event (the full text, from the text the reader already cached), the tool-call count and the
|
||||
reader's staleness. A finished agent is an error only if its result *begins* with one ("Error handling is missing" is not). There is no
|
||||
timeout event in the files, so `timeout` is a text match and the page says so. The board strip reads `N failed · M empty · K stale · …`
|
||||
and each failing row carries the first error line (masked, control characters stripped). The "re-run just the failed items" text is the
|
||||
documented `Workflow({ scriptPath, resumeFromRunId })` (ruflo-workflows: workflow-run: unchanged `agent()` calls return cached), split
|
||||
into agents that run again (no result recorded) and agents whose error *is* their recorded result and so stay cached. It is text for the
|
||||
person to run: the console never runs it, and says to stop a running run first because the console cannot stop or message a workflow.
|
||||
A ruflo swarm has no resume verb and is told so.
|
||||
|
||||
**Guards** (`data/wf-alerts.ts`, pure). Options `wfBudgetRunUsd`, `wfBudgetDayUsd` (0 off, else 0.01 to 10000) and `wfAlertRules`
|
||||
(`stuck>20m tokens>2M dirty`, words not understood are listed on the page). `evaluateGuards` is a pure function over the runs, the cost
|
||||
figures, progress marks and worktree readings; the notice slot `wf-guard` raises each alert once while it stays true (edge trigger) on
|
||||
top of the notice ring's 60 second dedupe. Like the page's own notices the first cost read announces nothing: what is already true then is listed on the Cost board ("now: …") and only a later crossing raises a notice. Alert keys are a short hash of the run and agent ids, because the ring cuts a key to 40 characters and two 17-character ids joined would collide. It only says: nothing is stopped, paused or messaged. `stuck` is measured from when the console
|
||||
first saw the agent unchanged (it cannot know how long it was quiet before), and a stale agent (no file activity for 15 minutes) counts at
|
||||
once. The 24h ceiling counts the workflow runs read on this page, not all Claude spend. `dirty` is a read-only
|
||||
`git --no-optional-locks -c core.fsmonitor=false -C <path> status --porcelain=v1 --untracked-files=no` of at most 12 ended agents'
|
||||
worktrees, fixed argv, only for paths inside the project (a repository's own config cannot run a program through it).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
Good: figures are from real files; every absence is said (no tracker, no price, unread, floor); no new write path.
|
||||
Costs: one more bounded read pass per refresh (benchmarked below); `stuck` and the notices lag one refresh tick behind the cost read
|
||||
because a notice slot has no host. Gaps: the day ceiling is not the account's day; no host-side "fill prompt" from a slot, so the re-run
|
||||
text is drawn for the person to copy.
|
||||
|
||||
## 4. Test and benchmark plan
|
||||
|
||||
`tests/wf-cost.spec.ts` (usage parse, book, lookup, per-bucket arithmetic, floors, option and rule limits, every guard at its boundary)
|
||||
and `tests/wf-triage.spec.ts` (classes, re-run text, the whole path on an in-memory disk: book, transcripts, byte budget, tail floor,
|
||||
dirty argv and path checks, notices once, masking). Mutation checks flip a boundary or a guard and must fail a test. Benchmark: the
|
||||
first refresh parses each finished transcript once (cost grows with transcript bytes, capped 24 MB per refresh); later refreshes of a
|
||||
finished run are a stat per agent. Measure a frame with the store warm: the slots do arithmetic over at most 60 agents.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
Remove the one import line from `views/wf-register.ts` and the one `refreshWfGuards` call: the page is the board again. The options
|
||||
are inert without them.
|
||||
|
||||
## 6. For the merge owner
|
||||
|
||||
`views/wf-register.ts`: `import './wf-triage'`. After `refreshWorkflows(...)` in the refresh tick and in `view-open.ts`: `void refreshWfGuards(state, host)`
|
||||
(from `wf-cost-live.ts`). `state.ts` `Options`/`optionsOf`: add `wfBudgetRunUsd`, `wfBudgetDayUsd`, `wfAlertRules` from
|
||||
`guardOptionsOf(raw)` (`data/wf-alerts.ts`). `plugin.json` `userConfig`:
|
||||
|
||||
```json
|
||||
"wfBudgetRunUsd": { "type": "number", "title": "Workflow run ceiling (USD)", "description": "A notice when one workflow run's spend reaches this (0 off, else 0.01 to 10000). The console only says so: it cannot stop a run.", "default": 0 },
|
||||
"wfBudgetDayUsd": { "type": "number", "title": "Workflow day ceiling (USD)", "description": "A notice when the workflow runs started in the last 24 hours reach this (0 off, else 0.01 to 10000).", "default": 0 },
|
||||
"wfAlertRules": { "type": "string", "title": "Workflow alert rules", "description": "Words separated by spaces: stuck>20m (no progress), tokens>2M (a run's tokens as the board header shows them: the agents' latest-request context sizes, not the Cost section's billed tokens), dirty (an ended agent left an uncommitted worktree).", "default": "" }
|
||||
```
|
||||
Reference in new issue
Block a user