@capagents/forgeqe 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +54 -3
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/handoff.ts +20 -0
- package/src/code/loop.ts +27 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/replay.ts +33 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- package/src/types.ts +11 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { BudgetConfig } from "../tokenomics/budget.ts"
|
|
5
|
+
import { readLedger, sessionCalls, type CallRecord } from "../tokenomics/ledger.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a report says about model usage: the report's own calls, one by one, and the last days of the ledger
|
|
9
|
+
* summed by day and by what the Breakdown page groups by, so a report shows this run and the history around it.
|
|
10
|
+
* Counts, names and times only: the ledger has no prompt or response text to carry.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type UsageCall = Omit<CallRecord, "v" | "proc">
|
|
14
|
+
|
|
15
|
+
/** One day's calls with the same agent, job, purpose, model, user, machine and trigger, summed. */
|
|
16
|
+
export type UsageRollup = {
|
|
17
|
+
day: string
|
|
18
|
+
agent: string
|
|
19
|
+
job: string
|
|
20
|
+
purpose: string
|
|
21
|
+
model: string
|
|
22
|
+
user: string
|
|
23
|
+
machine: string
|
|
24
|
+
trigger: string
|
|
25
|
+
calls: number
|
|
26
|
+
input: number
|
|
27
|
+
cached: number
|
|
28
|
+
cacheWrite: number
|
|
29
|
+
output: number
|
|
30
|
+
/** Cost of the priced calls, in `currency`; calls in another currency are left out of the sum. */
|
|
31
|
+
amount: number
|
|
32
|
+
currency?: string
|
|
33
|
+
unpriced: number
|
|
34
|
+
errors: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UsageData = {
|
|
38
|
+
calls: UsageCall[]
|
|
39
|
+
history: UsageRollup[]
|
|
40
|
+
historyDays: number
|
|
41
|
+
budget?: BudgetConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const HISTORY_DAYS = 30
|
|
45
|
+
|
|
46
|
+
const strip = ({ v: _v, proc: _p, ...c }: CallRecord): UsageCall => c
|
|
47
|
+
|
|
48
|
+
/** usage.json in a run folder: its calls, or none when it was not written (a run before the ledger). */
|
|
49
|
+
export function readRunUsage(runDir?: string): CallRecord[] {
|
|
50
|
+
const file = runDir ? path.join(runDir, "usage.json") : ""
|
|
51
|
+
if (!file || !existsSync(file)) return []
|
|
52
|
+
try {
|
|
53
|
+
const calls = (JSON.parse(readFileSync(file, "utf8")) as { calls?: CallRecord[] }).calls
|
|
54
|
+
return Array.isArray(calls) ? calls : []
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The ledger's last `days` days, summed per day and grouping; most recent day first. */
|
|
61
|
+
export function usageHistory(days = HISTORY_DAYS, opts: { dir?: string; now?: Date } = {}): UsageRollup[] {
|
|
62
|
+
const now = opts.now || new Date()
|
|
63
|
+
const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)))
|
|
64
|
+
const sums = new Map<string, UsageRollup>()
|
|
65
|
+
const currencies = new Map<string, number>()
|
|
66
|
+
const calls = readLedger({ since, dir: opts.dir })
|
|
67
|
+
for (const c of calls) if (c.currency) currencies.set(c.currency, (currencies.get(c.currency) || 0) + 1)
|
|
68
|
+
// One currency is summed: the one most calls were priced in.
|
|
69
|
+
const currency = [...currencies.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
70
|
+
for (const c of calls) {
|
|
71
|
+
const day = c.ts.slice(0, 10)
|
|
72
|
+
const key = [day, c.agent, c.job, c.purpose, c.model, c.user, c.machine, c.trigger].join("\u0000")
|
|
73
|
+
const r =
|
|
74
|
+
sums.get(key) ||
|
|
75
|
+
({ day, agent: c.agent, job: c.job, purpose: c.purpose, model: c.model, user: c.user, machine: c.machine, trigger: c.trigger, calls: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, amount: 0, ...(currency ? { currency } : {}), unpriced: 0, errors: 0 } as UsageRollup)
|
|
76
|
+
r.calls++
|
|
77
|
+
r.input += c.input
|
|
78
|
+
r.cached += c.cached || 0
|
|
79
|
+
r.cacheWrite += c.cacheWrite || 0
|
|
80
|
+
r.output += c.output
|
|
81
|
+
if (c.amount !== undefined && c.currency === currency) r.amount += c.amount
|
|
82
|
+
else r.unpriced++
|
|
83
|
+
if (c.outcome === "error") r.errors++
|
|
84
|
+
sums.set(key, r)
|
|
85
|
+
}
|
|
86
|
+
return [...sums.values()].sort((a, b) => b.day.localeCompare(a.day) || b.amount - a.amount)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A report's usage. A run: the calls of its folder. A suite: the calls of each case's run folder, and the calls this
|
|
91
|
+
* process made while the suite ran (ForgeQE's agent, Scribe, heals), each once. Undefined when there are none.
|
|
92
|
+
*/
|
|
93
|
+
export function reportUsage(p: { kind: "suite" | "run"; dir: string; startedAt: string; runDirs: string[]; budget?: BudgetConfig }, opts: { historyDays?: number; dir?: string } = {}): UsageData | undefined {
|
|
94
|
+
const seen = new Set<string>()
|
|
95
|
+
const calls: CallRecord[] = []
|
|
96
|
+
const add = (c: CallRecord) => {
|
|
97
|
+
if (!c?.id || seen.has(c.id)) return
|
|
98
|
+
seen.add(c.id)
|
|
99
|
+
calls.push(c)
|
|
100
|
+
}
|
|
101
|
+
const dirs = p.kind === "run" ? [p.dir, ...p.runDirs] : p.runDirs
|
|
102
|
+
for (const d of new Set(dirs)) for (const c of readRunUsage(d)) add(c)
|
|
103
|
+
if (p.kind === "run") for (const c of sessionCalls((c) => c.runId === path.basename(p.dir))) add(c)
|
|
104
|
+
if (p.kind === "suite") for (const c of sessionCalls((c) => c.ts >= p.startedAt)) add(c)
|
|
105
|
+
const history = usageHistory(opts.historyDays ?? HISTORY_DAYS, { dir: opts.dir })
|
|
106
|
+
if (!calls.length && !history.length) return undefined
|
|
107
|
+
return {
|
|
108
|
+
calls: calls.sort((a, b) => a.ts.localeCompare(b.ts)).map(strip),
|
|
109
|
+
history,
|
|
110
|
+
historyDays: opts.historyDays ?? HISTORY_DAYS,
|
|
111
|
+
...(p.budget ? { budget: p.budget } : {}),
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { saveApiSteps } from "./api/fromSteps.ts"
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
3
|
import { createRequire } from "node:module"
|
|
3
4
|
import path from "node:path"
|
|
@@ -12,6 +13,7 @@ import { ensureEngine } from "./engine/setup.ts"
|
|
|
12
13
|
import { reportEnv, writeRunReport } from "./report/html.ts"
|
|
13
14
|
import { logEvent } from "./sessionLog.ts"
|
|
14
15
|
import { costOf } from "./usage.ts"
|
|
16
|
+
import { writeRunUsage } from "./tokenomics/ledger.ts"
|
|
15
17
|
import { allLogins, redactSecrets } from "./sessionLogins.ts"
|
|
16
18
|
import { browserSkills, rememberChoice } from "./skills/select.ts"
|
|
17
19
|
import { axeTags, DEFAULT_QUALITY, emptyReport, evaluateQuality, readA11y } from "./quality/budgets.ts"
|
|
@@ -19,10 +21,13 @@ import { historyFile, recordQuality } from "./quality/history.ts"
|
|
|
19
21
|
import { compareVisual, engineVisual } from "./quality/visual.ts"
|
|
20
22
|
import { enginePrompts, prompt } from "./prompts.ts"
|
|
21
23
|
import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts"
|
|
24
|
+
import { registryMode } from "./registry/index.ts"
|
|
22
25
|
|
|
23
26
|
export type ExecuteOptions = {
|
|
24
27
|
config: AppConfig
|
|
25
28
|
goal: string
|
|
29
|
+
/** The test case the run is for: its API steps are saved as that case's scenario. */
|
|
30
|
+
caseRef?: { caseId?: string; caseTitle?: string }
|
|
26
31
|
browserProvider?: BrowserProvider
|
|
27
32
|
/** A named device from the provider files (browser_devices); it also picks the provider. */
|
|
28
33
|
browserDevice?: string
|
|
@@ -36,6 +41,8 @@ export type ExecuteOptions = {
|
|
|
36
41
|
memory?: boolean
|
|
37
42
|
/** Steps in the test case this goal came from; the agent then reports a verdict for each. */
|
|
38
43
|
caseSteps?: number
|
|
44
|
+
/** Each case step's stepKey: after the case is edited, the engine replays the steps before the first one that changed. */
|
|
45
|
+
caseStepKeys?: string[]
|
|
39
46
|
/** A failed or cut-off run's folder (or its name under the runs folder): replay the steps that worked there first. */
|
|
40
47
|
resumeFrom?: string
|
|
41
48
|
/** Browser skills the caller chose (empty: none); unset: the engine picks the ones the goal needs. */
|
|
@@ -131,6 +138,16 @@ function shortPage(url: string): string {
|
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Page memory for the engine, or undefined to run without it. registry.mode on (the default): a replay may also be
|
|
143
|
+
* passed by its recorded checks, with no model (docs/COST_ARCHITECTURE.md); off or shadow: as it always was.
|
|
144
|
+
*/
|
|
145
|
+
export function engineMemory(opts: Pick<ExecuteOptions, "config" | "replay" | "memory">): { dir: string; replay: ReplayMode; checks_first?: boolean } | undefined {
|
|
146
|
+
const mem = opts.config.memory
|
|
147
|
+
if (!mem.enabled || opts.memory === false) return undefined
|
|
148
|
+
return { dir: mem.dir, replay: opts.replay || mem.replay, ...(registryMode(opts.config) === "on" ? { checks_first: true } : {}) }
|
|
149
|
+
}
|
|
150
|
+
|
|
134
151
|
export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunResult; files: string[] }> {
|
|
135
152
|
const goal = redactSecrets(opts.goal.trim())
|
|
136
153
|
if (!goal) throw new Error("A goal is required. Pass --goal or type one in the shell.")
|
|
@@ -156,6 +173,19 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
156
173
|
const withReport = (out: { result: RunResult; files: string[] }) => {
|
|
157
174
|
out.result.cost = costOf(out.result.usage, opts.config.llm.price)
|
|
158
175
|
applyQuality(opts.config, dir, out.result, opts.onEvent)
|
|
176
|
+
// API steps the run made in the browser's session, kept as a scenario the API tools run again without a browser.
|
|
177
|
+
try {
|
|
178
|
+
const api = saveApiSteps(opts.config, dir, opts.caseRef || {})
|
|
179
|
+
if (api) out.result.api = { id: api.id, dir: api.dir, calls: api.calls }
|
|
180
|
+
} catch (err) {
|
|
181
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `API steps not saved as a scenario: ${(err as Error).message}` })
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const usage = writeRunUsage(dir)
|
|
185
|
+
if (usage) out.files.push(usage)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
opts.onEvent?.({ type: "log", level: "warn", text: `usage.json not written: ${(err as Error).message}` })
|
|
188
|
+
}
|
|
159
189
|
logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
|
|
160
190
|
try {
|
|
161
191
|
out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
|
|
@@ -203,8 +233,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
203
233
|
BROWSER_USE_USE_JUDGE: opts.config.browser.judge ? "true" : "false",
|
|
204
234
|
}
|
|
205
235
|
for (const [k, v] of Object.entries(tuning)) if (!process.env[k]) env[k] = v
|
|
206
|
-
const
|
|
207
|
-
const memory = mem.enabled && opts.memory !== false ? { dir: mem.dir, replay: opts.replay || mem.replay } : undefined
|
|
236
|
+
const memory = engineMemory(opts)
|
|
208
237
|
const resume = opts.resumeFrom ? loadResume(opts.config.export.outDir, opts.resumeFrom) : undefined
|
|
209
238
|
if (resume && !memory) throw new Error("resume_from replays remembered steps, so it needs page memory; it is off for this run.")
|
|
210
239
|
const q = opts.config.quality || DEFAULT_QUALITY
|
|
@@ -231,6 +260,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
|
|
|
231
260
|
env,
|
|
232
261
|
logins: allLogins(opts.config),
|
|
233
262
|
caseSteps: opts.caseSteps,
|
|
263
|
+
...(opts.caseRef?.caseId && opts.caseStepKeys?.length ? { caseId: opts.caseRef.caseId, caseStepKeys: opts.caseStepKeys } : {}),
|
|
234
264
|
screenshots: opts.config.report.screenshots,
|
|
235
265
|
accessibility: opts.config.report.accessibility,
|
|
236
266
|
network: opts.config.api?.capture !== false,
|
package/src/suite/report.ts
CHANGED
|
@@ -77,11 +77,28 @@ export function suiteUsage(results: CaseResult[], analysis?: { usage: Usage; cos
|
|
|
77
77
|
return { usage, cost: priced ? cost : undefined }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The suite's headline, saying what each part did: the cases, and the tests written from them. A suite whose cases
|
|
82
|
+
* all passed but whose test did not pass is not called failed with "1/1 passed" beside it.
|
|
83
|
+
*/
|
|
84
|
+
export function suiteHeadline(s: Pick<SuiteResult, "results" | "stopped">): { mark: "✓" | "✗" | "■"; title: string; counts: string; ok: boolean } {
|
|
85
|
+
const c = counts(s.results)
|
|
86
|
+
const coded = s.results.filter((r) => r.code)
|
|
87
|
+
const testsOk = coded.filter((r) => r.code!.status === "passed").length
|
|
88
|
+
const casesOk = c.passed === c.total
|
|
89
|
+
const testsLine = coded.length ? ` · ${testsOk}/${coded.length} ${coded.length === 1 ? "test" : "tests"} passing` : ""
|
|
90
|
+
const countsLine = `${c.passed}/${c.total} ${c.total === 1 ? "case" : "cases"} passed${testsLine}`
|
|
91
|
+
if (s.stopped) return { mark: "■", title: "Suite stopped", counts: countsLine, ok: false }
|
|
92
|
+
if (!casesOk) return { mark: "✗", title: "Suite failed", counts: countsLine, ok: false }
|
|
93
|
+
if (testsOk < coded.length) return { mark: "✗", title: `${c.total === 1 ? "Case" : "Cases"} passed, ${coded.length - testsOk === 1 ? "test" : "tests"} not passing`, counts: countsLine, ok: false }
|
|
94
|
+
return { mark: "✓", title: "Suite passed", counts: countsLine, ok: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/** The end-of-suite summary for the terminal. */
|
|
81
98
|
export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
|
|
82
|
-
const c = counts(s.results)
|
|
83
99
|
const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
|
|
84
|
-
const
|
|
100
|
+
const h = suiteHeadline(s)
|
|
101
|
+
const head = `${h.mark} ${h.title} · ${h.counts} · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
|
|
85
102
|
const show = (f: string) => path.relative(cwd, f) || f
|
|
86
103
|
const total = suiteUsage(s.results, s.analysis)
|
|
87
104
|
const lines = [head, ""]
|
|
@@ -116,7 +133,8 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
|
|
|
116
133
|
export function markdownSummary(s: SuiteResult): string {
|
|
117
134
|
const c = counts(s.results)
|
|
118
135
|
const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
|
|
119
|
-
const
|
|
136
|
+
const h = suiteHeadline(s)
|
|
137
|
+
const head = `${h.mark} ${h.title}`
|
|
120
138
|
const rows = s.results.map((r) => {
|
|
121
139
|
const test = r.code ? `${r.code.status}${r.code.testFile ? ` \`${cell(r.code.testFile)}\`` : ""}` : ""
|
|
122
140
|
const steps = r.stepResults?.length && r.case.steps.length ? `${r.stepResults.filter((v) => v.status === "passed").length}/${r.case.steps.length}` : ""
|
|
@@ -140,7 +158,7 @@ export function markdownSummary(s: SuiteResult): string {
|
|
|
140
158
|
]
|
|
141
159
|
: []
|
|
142
160
|
return [
|
|
143
|
-
`### ${head} · ${
|
|
161
|
+
`### ${head} · ${h.counts} · ${duration(s.ms)}`,
|
|
144
162
|
"",
|
|
145
163
|
"| | Case | Title | Steps | Time | Result | Test |",
|
|
146
164
|
"|---|---|---|---|---|---|---|",
|
package/src/suite/run.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
4
|
import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
|
|
5
|
-
import { costOf, type Cost } from "../cases/types.ts"
|
|
5
|
+
import { costOf, stepKey, stepsHash, type Cost } from "../cases/types.ts"
|
|
6
6
|
import { addUsage, NO_USAGE } from "../usage.ts"
|
|
7
7
|
import type { AgentEvent, CodeResult, ReplayMode, RunResult, Usage } from "../types.ts"
|
|
8
8
|
import { codeSkipReason, runCodeAgent } from "../code/index.ts"
|
|
@@ -15,7 +15,7 @@ import { reproPack, writeReproFiles } from "../cases/repro.ts"
|
|
|
15
15
|
import { commentAnalyses } from "../cases/bugs.ts"
|
|
16
16
|
import { caseOutcomes, defectHistoryFile, failedInARow, noteHistory, readDefectHistory, recordRun, writeDefectHistory } from "../cases/defectHistory.ts"
|
|
17
17
|
import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
|
|
18
|
-
import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
|
|
18
|
+
import { associateAdoAutomation, readMappings, recordMapping } from "../code/traceability.ts"
|
|
19
19
|
import type { ExistingTest } from "../code/prompt.ts"
|
|
20
20
|
import { runTestCommand } from "../code/testrun.ts"
|
|
21
21
|
import { privateEnvNames } from "../childEnv.ts"
|
|
@@ -151,6 +151,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
151
151
|
const { result } = await execute({
|
|
152
152
|
config,
|
|
153
153
|
goal: goalFor(original),
|
|
154
|
+
caseRef: { caseId: original.id, caseTitle: original.title },
|
|
154
155
|
browserProvider: opts.browserProvider,
|
|
155
156
|
browserDevice: device,
|
|
156
157
|
url: tc.startUrl || opts.url,
|
|
@@ -159,6 +160,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
159
160
|
replay: opts.replay,
|
|
160
161
|
memory: opts.memory,
|
|
161
162
|
caseSteps: tc.steps.length,
|
|
163
|
+
caseStepKeys: original.steps.map(stepKey),
|
|
162
164
|
outDir,
|
|
163
165
|
skills: opts.skills,
|
|
164
166
|
repo: opts.skillsRepo || opts.repo,
|
|
@@ -263,16 +265,20 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
263
265
|
const run = await (opts.runTest || runTestCommand)({ command: mapped.command, cwd: opts.repo!, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, hide: privateEnvNames(config), save: path.join(runDir, "existing-test") })
|
|
264
266
|
const defectNow = result.failure?.type === "functional_defect"
|
|
265
267
|
// Reused as it is: a normal test that passes on a case that passes, or a known-defect test (an expected failure)
|
|
266
|
-
// on a case that still shows the defect.
|
|
268
|
+
// on a case that still shows the defect, as long as the case's steps are the ones the test was written from.
|
|
267
269
|
const fits = mapped.status === "known_defect" ? defectNow : !defectNow
|
|
268
|
-
|
|
270
|
+
const hash = tc.steps.length ? stepsHash(tc.steps) : undefined
|
|
271
|
+
const changed = Boolean(mapped.stepsHash && hash && mapped.stepsHash !== hash)
|
|
272
|
+
if (run.ok && fits && !changed) {
|
|
273
|
+
// A mapping from before steps were fingerprinted gets this case's: a later change to the steps then shows.
|
|
274
|
+
if (hash && !mapped.stepsHash) recordMapping(opts.repo!, { ...mapped, stepsHash: hash })
|
|
269
275
|
emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "test", ok: true, attempt: 0, text: `${mapped.testFile} passes as it is: no new test needed` } })
|
|
270
276
|
result.code = { status: "passed", testFile: mapped.testFile, command: mapped.command, attempts: 0, existing: "reused", ...(mapped.testName ? { testName: mapped.testName } : {}), ...(mapped.status === "known_defect" ? { knownDefect: true } : {}) }
|
|
271
277
|
emit({ type: "code-done", index, case: tc, result })
|
|
272
278
|
return
|
|
273
279
|
}
|
|
274
280
|
if (opts.signal?.aborted) return
|
|
275
|
-
const
|
|
281
|
+
const reason =
|
|
276
282
|
mapped.status === "known_defect" && !defectNow
|
|
277
283
|
? `It is marked as an expected failure for a known defect${mapped.bug ? ` (${mapped.bug.label})` : ""}, and the browser run now passes: the defect looks fixed. Remove the expected-failure marker and keep the assertions.`
|
|
278
284
|
: defectNow && run.ok
|
|
@@ -280,6 +286,8 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
|
|
|
280
286
|
: defectNow
|
|
281
287
|
? "It fails because of the functional defect this run found (below): keep its assertions and mark it as an expected failure naming the defect."
|
|
282
288
|
: "It fails now: the application or the page changed since it was written."
|
|
289
|
+
const stale = "The test case's steps changed since this test was written (for example after a review): make the test do the case's steps and check its expected results as they are now, keeping the parts that still fit."
|
|
290
|
+
const why = !changed ? reason : run.ok && fits ? stale : `${stale} ${reason}`
|
|
283
291
|
existingTest = { testFile: mapped.testFile, command: mapped.command, output: run.output, why }
|
|
284
292
|
}
|
|
285
293
|
emit({ type: "code-start", index, case: tc })
|
package/src/summary.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type RunSummary = {
|
|
|
25
25
|
usage: string
|
|
26
26
|
/** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
|
|
27
27
|
quality?: string
|
|
28
|
+
/** The API steps saved as a reusable scenario: "2 calls saved as API scenario 20261013-…" and where. */
|
|
29
|
+
api?: string
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export const STATUS_TITLE: Record<RunStatus, string> = {
|
|
@@ -60,6 +62,7 @@ export function memoryLine(m?: MemoryInfo): string {
|
|
|
60
62
|
parts.push(m.replayed === m.replayTotal ? `replayed all ${m.replayed} steps` : `replayed ${m.replayed} of ${m.replayTotal} steps`)
|
|
61
63
|
parts.push(plural(m.llmSteps, "LLM step"))
|
|
62
64
|
}
|
|
65
|
+
if (m.checked) parts.push(m.checksHeld === m.checked ? `${plural(m.checked, "recorded check")} held` : `${m.checksHeld || 0} of ${plural(m.checked, "recorded check")} held`)
|
|
63
66
|
if (m.elements) parts.push(`${plural(m.elements, "element")} on ${plural(m.pages, "page")}${m.newElements ? ` (${m.newElements} new)` : ""}`)
|
|
64
67
|
if (m.flow) parts.push(m.flow === "saved" ? "flow saved for replay" : "flow updated")
|
|
65
68
|
return parts.join(" · ")
|
|
@@ -80,6 +83,7 @@ export function buildSummary(opts: {
|
|
|
80
83
|
failedChecks?: { expected: string; actual: string }[]
|
|
81
84
|
quality?: QualityReport
|
|
82
85
|
budgetExceeded?: string[]
|
|
86
|
+
api?: { id: string; dir: string; calls: number }
|
|
83
87
|
}): RunSummary {
|
|
84
88
|
const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
|
|
85
89
|
const assertion = opts.outcome === "assertion_failed"
|
|
@@ -97,6 +101,7 @@ export function buildSummary(opts: {
|
|
|
97
101
|
savedFiles: opts.files.map((f) => path.basename(f)),
|
|
98
102
|
usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
|
|
99
103
|
...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
|
|
104
|
+
...(opts.api ? { api: `${opts.api.calls} ${opts.api.calls === 1 ? "call" : "calls"} saved as a reusable API scenario · ${path.relative(process.cwd(), opts.api.dir) || opts.api.dir}/` } : {}),
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
|
|
@@ -106,6 +111,7 @@ export function formatSummary(s: RunSummary): string {
|
|
|
106
111
|
if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
|
|
107
112
|
if (s.memory) lines.push(`Memory ${s.memory}`)
|
|
108
113
|
if (s.quality) lines.push(`Quality ${s.quality}`)
|
|
114
|
+
if (s.api) lines.push(`API ${s.api}`)
|
|
109
115
|
if (s.usage) lines.push(`Usage ${s.usage}`)
|
|
110
116
|
if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
|
|
111
117
|
return lines.join("\n")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { formatCost } from "../usage.ts"
|
|
5
|
+
import { onCall, usageDir, type CallRecord } from "./ledger.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Spending budgets (budget: in forgeqe.yaml), per run, day and month, in the price currency. They only warn, once
|
|
9
|
+
* each at 80%, 90% and 100%, and never stop a run. Spend is read from the ledger file, which every ForgeQE process
|
|
10
|
+
* on this machine appends to: a browser run the agent starts through the MCP server runs in another process, and
|
|
11
|
+
* its calls count all the same. Days and months are UTC, as the ledger's timestamps are.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type BudgetConfig = { run?: number; day?: number; month?: number; currency?: string }
|
|
15
|
+
export type BudgetPeriod = "run" | "day" | "month"
|
|
16
|
+
|
|
17
|
+
export type BudgetWarning = { period: BudgetPeriod; threshold: number; spent: number; limit: number; currency: string; text: string }
|
|
18
|
+
|
|
19
|
+
export const BUDGET_THRESHOLDS = [80, 90, 100] as const
|
|
20
|
+
|
|
21
|
+
/** How often the ledger is read again for calls other processes made. */
|
|
22
|
+
const POLL_MS = 5_000
|
|
23
|
+
|
|
24
|
+
/** budget: in forgeqe.yaml; amounts that are not positive numbers are left out. */
|
|
25
|
+
export function budgetConfig(raw: unknown): BudgetConfig | undefined {
|
|
26
|
+
const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null
|
|
27
|
+
if (!r) return undefined
|
|
28
|
+
const amount = (v: unknown) => (typeof v === "number" || (typeof v === "string" && v.trim() !== "") ? (Number(v) > 0 ? Number(v) : undefined) : undefined)
|
|
29
|
+
const out: BudgetConfig = {}
|
|
30
|
+
for (const k of ["run", "day", "month"] as const) {
|
|
31
|
+
const a = amount(r[k])
|
|
32
|
+
if (a !== undefined) out[k] = a
|
|
33
|
+
}
|
|
34
|
+
if (typeof r.currency === "string" && /^[A-Za-z]{3}$/.test(r.currency.trim())) out.currency = r.currency.trim().toUpperCase()
|
|
35
|
+
return out.run || out.day || out.month ? out : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BudgetStatus = { period: BudgetPeriod; spent: number; limit: number; currency: string; percent: number }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Watch spend against `cfg` and call `warn` the first time each period passes each threshold. Returns the
|
|
42
|
+
* unsubscribe and a way to read where each budget stands.
|
|
43
|
+
*/
|
|
44
|
+
export function watchBudgets(cfg: BudgetConfig, warn: (w: BudgetWarning) => void, opts: { dir?: string; now?: () => Date; pollMs?: number } = {}) {
|
|
45
|
+
const now = opts.now || (() => new Date())
|
|
46
|
+
const dir = () => opts.dir || usageDir()
|
|
47
|
+
const warned = new Set<string>()
|
|
48
|
+
const seen = new Set<string>()
|
|
49
|
+
let currency = cfg.currency
|
|
50
|
+
let month = ""
|
|
51
|
+
let offset = 0
|
|
52
|
+
let monthAmount = 0
|
|
53
|
+
const days = new Map<string, number>()
|
|
54
|
+
const runs = new Map<string, number>()
|
|
55
|
+
const touched = new Set<string>()
|
|
56
|
+
|
|
57
|
+
const add = (c: CallRecord) => {
|
|
58
|
+
if (!c?.id || seen.has(c.id) || c.amount === undefined || !c.ts?.startsWith(month)) return
|
|
59
|
+
seen.add(c.id)
|
|
60
|
+
currency ||= cfg.currency || c.currency
|
|
61
|
+
if (c.currency !== currency) return
|
|
62
|
+
monthAmount += c.amount
|
|
63
|
+
days.set(c.ts.slice(0, 10), (days.get(c.ts.slice(0, 10)) || 0) + c.amount)
|
|
64
|
+
if (c.runId) {
|
|
65
|
+
runs.set(c.runId, (runs.get(c.runId) || 0) + c.amount)
|
|
66
|
+
touched.add(c.runId)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read what was appended to this month's file since the last read; a new month starts over. */
|
|
71
|
+
const sync = () => {
|
|
72
|
+
const m = now().toISOString().slice(0, 7)
|
|
73
|
+
if (m !== month) {
|
|
74
|
+
month = m
|
|
75
|
+
offset = 0
|
|
76
|
+
monthAmount = 0
|
|
77
|
+
days.clear()
|
|
78
|
+
runs.clear()
|
|
79
|
+
seen.clear()
|
|
80
|
+
}
|
|
81
|
+
const file = path.join(dir(), `${month}.jsonl`)
|
|
82
|
+
if (!existsSync(file)) return
|
|
83
|
+
try {
|
|
84
|
+
const size = statSync(file).size
|
|
85
|
+
if (size < offset) offset = 0
|
|
86
|
+
if (size === offset) return
|
|
87
|
+
const fd = openSync(file, "r")
|
|
88
|
+
const buf = Buffer.alloc(size - offset)
|
|
89
|
+
try {
|
|
90
|
+
readSync(fd, buf, 0, buf.length, offset)
|
|
91
|
+
} finally {
|
|
92
|
+
closeSync(fd)
|
|
93
|
+
}
|
|
94
|
+
// Only whole lines: a line another process is still writing is read next time.
|
|
95
|
+
const text = buf.toString("utf8")
|
|
96
|
+
const end = text.lastIndexOf("\n")
|
|
97
|
+
if (end < 0) return
|
|
98
|
+
offset += Buffer.byteLength(text.slice(0, end + 1))
|
|
99
|
+
for (const line of text.slice(0, end).split("\n")) {
|
|
100
|
+
try {
|
|
101
|
+
add(JSON.parse(line) as CallRecord)
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const check = () => {
|
|
108
|
+
const day = now().toISOString().slice(0, 10)
|
|
109
|
+
const targets: { period: BudgetPeriod; key: string; spent: number }[] = []
|
|
110
|
+
if (cfg.day) targets.push({ period: "day", key: day, spent: days.get(day) || 0 })
|
|
111
|
+
if (cfg.month) targets.push({ period: "month", key: month, spent: monthAmount })
|
|
112
|
+
if (cfg.run) for (const id of touched) targets.push({ period: "run", key: id, spent: runs.get(id) || 0 })
|
|
113
|
+
touched.clear()
|
|
114
|
+
for (const { period, key, spent } of targets) {
|
|
115
|
+
const limit = cfg[period]!
|
|
116
|
+
// Only the highest threshold newly passed is said: a jump from 70% to 105% is one warning, not three.
|
|
117
|
+
const passed = BUDGET_THRESHOLDS.filter((t) => spent >= (limit * t) / 100 && !warned.has(`${period}:${key}:${t}`))
|
|
118
|
+
if (!passed.length) continue
|
|
119
|
+
for (const t of passed) warned.add(`${period}:${key}:${t}`)
|
|
120
|
+
const threshold = passed[passed.length - 1]!
|
|
121
|
+
const cur = currency || "USD"
|
|
122
|
+
warn({ period, threshold, spent, limit, currency: cur, text: warningText(period, threshold, spent, limit, cur) })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Spend so far this month is counted, but only what passes a threshold from now on is said.
|
|
127
|
+
sync()
|
|
128
|
+
for (const [period, key, spent] of [["day", now().toISOString().slice(0, 10), days.get(now().toISOString().slice(0, 10)) || 0], ["month", month, monthAmount]] as const) {
|
|
129
|
+
const limit = cfg[period]
|
|
130
|
+
if (limit) for (const t of BUDGET_THRESHOLDS) if (spent >= (limit * t) / 100) warned.add(`${period}:${key}:${t}`)
|
|
131
|
+
}
|
|
132
|
+
touched.clear()
|
|
133
|
+
|
|
134
|
+
const stopListening = onCall((c) => {
|
|
135
|
+
sync()
|
|
136
|
+
add(c) // the ledger is off (FORGEQE_USAGE=off) or the line was not written: count it all the same
|
|
137
|
+
check()
|
|
138
|
+
})
|
|
139
|
+
const timer = setInterval(() => {
|
|
140
|
+
sync()
|
|
141
|
+
check()
|
|
142
|
+
}, opts.pollMs ?? POLL_MS)
|
|
143
|
+
timer.unref?.()
|
|
144
|
+
|
|
145
|
+
const stop = () => {
|
|
146
|
+
stopListening()
|
|
147
|
+
clearInterval(timer)
|
|
148
|
+
}
|
|
149
|
+
const status = (runId?: string): BudgetStatus[] => {
|
|
150
|
+
sync()
|
|
151
|
+
const day = now().toISOString().slice(0, 10)
|
|
152
|
+
const out: BudgetStatus[] = []
|
|
153
|
+
const push = (period: BudgetPeriod, spent: number) => {
|
|
154
|
+
const limit = cfg[period]
|
|
155
|
+
if (limit) out.push({ period, spent, limit, currency: currency || "USD", percent: Math.round((spent / limit) * 100) })
|
|
156
|
+
}
|
|
157
|
+
if (runId) push("run", runs.get(runId) || 0)
|
|
158
|
+
push("day", days.get(day) || 0)
|
|
159
|
+
push("month", monthAmount)
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
return { stop, status }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const WHOSE: Record<BudgetPeriod, string> = { run: "this run's", day: "today's", month: "this month's" }
|
|
166
|
+
|
|
167
|
+
/** "Budget: this month's model spend is at $245.10, 82% of the $300.00 month budget (…). Nothing is stopped." */
|
|
168
|
+
export function warningText(period: BudgetPeriod, threshold: number, spent: number, limit: number, currency: string): string {
|
|
169
|
+
const money = (amount: number) => formatCost({ amount, currency })
|
|
170
|
+
const verb = threshold >= 100 ? "has reached" : "is at"
|
|
171
|
+
return `Budget: ${WHOSE[period]} model spend ${verb} ${money(spent)}, ${Math.round((spent / limit) * 100)}% of the ${money(limit)} ${period} budget (budget.${period} in forgeqe.yaml). Nothing is stopped.`
|
|
172
|
+
}
|