@capagents/forgeqe 0.0.0-stage → 1.0.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 +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { Card } from "../card.ts"
|
|
5
|
+
import { loadCases, type TestCase } from "../cases/index.ts"
|
|
6
|
+
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import { counts, suiteUsage } from "../suite/report.ts"
|
|
8
|
+
import { runSuite, type SuiteEvent, type SuiteOptions, type SuiteResult } from "../suite/run.ts"
|
|
9
|
+
import { formatDuration, formatTokens } from "../tui/theme.ts"
|
|
10
|
+
import { usageLine } from "../usage.ts"
|
|
11
|
+
import { alertTitle, itemLine, sendAlerts, type AlertMessage, type Sent } from "./alerts.ts"
|
|
12
|
+
import { MONITOR_CHECK, decide, readState, writeState, type AlertItem, type CheckOutcome, type CheckState } from "./state.ts"
|
|
13
|
+
|
|
14
|
+
export type RoundResult = {
|
|
15
|
+
at: string
|
|
16
|
+
suite?: SuiteResult
|
|
17
|
+
/** Why the round could not run at all. */
|
|
18
|
+
error?: string
|
|
19
|
+
alerts: AlertItem[]
|
|
20
|
+
sent: Sent[]
|
|
21
|
+
/** Checks failing after this round, alerted or not. */
|
|
22
|
+
failing: (CheckState & { id: string })[]
|
|
23
|
+
failAfter: number
|
|
24
|
+
stopped: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type MonitorOptions = {
|
|
28
|
+
config: AppConfig
|
|
29
|
+
/** Test case sources; default monitor.checks. */
|
|
30
|
+
checks: string[]
|
|
31
|
+
headed?: boolean
|
|
32
|
+
url?: string
|
|
33
|
+
/** Browser skills for every check (empty: none); unset: the engine picks once per check for the monitor's lifetime. */
|
|
34
|
+
skills?: string[]
|
|
35
|
+
signal?: AbortSignal
|
|
36
|
+
onEvent?: (e: SuiteEvent) => void
|
|
37
|
+
/** Replace loading cases, running them, and sending alerts; tests use them. */
|
|
38
|
+
load?: (refs: string[], cfg: AppConfig) => Promise<TestCase[]>
|
|
39
|
+
execute?: SuiteOptions["execute"]
|
|
40
|
+
send?: typeof sendAlerts
|
|
41
|
+
now?: () => Date
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The round's report as a link: under monitor.report_url when set, else the local file. */
|
|
45
|
+
function reportLinks(cfg: AppConfig, suite?: SuiteResult): { report?: string; reportUrl?: string } {
|
|
46
|
+
if (!suite) return {}
|
|
47
|
+
const base = cfg.monitor.reportUrl
|
|
48
|
+
return { report: suite.files.html, ...(base ? { reportUrl: `${base}/${path.basename(suite.dir)}/report.html` } : {}) }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One monitoring round: run every check as a suite case (replaying page memory, so a known flow needs one model
|
|
53
|
+
* call to confirm the outcome, or none with replay trust), update the saved state, and alert on what changed.
|
|
54
|
+
*/
|
|
55
|
+
export async function monitorRound(opts: MonitorOptions): Promise<RoundResult> {
|
|
56
|
+
const { config } = opts
|
|
57
|
+
const m = config.monitor
|
|
58
|
+
const now = opts.now || (() => new Date())
|
|
59
|
+
const at = now()
|
|
60
|
+
const prev = readState(m.outDir)
|
|
61
|
+
const policy = { failAfter: m.failAfter, remindMs: m.remindMs }
|
|
62
|
+
let suite: SuiteResult | undefined
|
|
63
|
+
let error: string | undefined
|
|
64
|
+
try {
|
|
65
|
+
const cases = await (opts.load || ((refs, cfg) => loadCases(refs, cfg.sources)))(opts.checks, config)
|
|
66
|
+
if (!cases.length) throw new Error(`No checks found in ${opts.checks.join(" ")}.`)
|
|
67
|
+
suite = await runSuite({
|
|
68
|
+
config: { ...config, suite: { ...config.suite, outDir: m.outDir, code: false } },
|
|
69
|
+
cases,
|
|
70
|
+
parallel: Math.min(m.parallel, cases.length),
|
|
71
|
+
writeBack: m.writeBack,
|
|
72
|
+
replay: m.replay,
|
|
73
|
+
headed: opts.headed,
|
|
74
|
+
url: opts.url,
|
|
75
|
+
skills: opts.skills,
|
|
76
|
+
signal: opts.signal,
|
|
77
|
+
onEvent: opts.onEvent,
|
|
78
|
+
execute: opts.execute,
|
|
79
|
+
})
|
|
80
|
+
} catch (err) {
|
|
81
|
+
error = (err as Error).message || String(err)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stopped = Boolean(opts.signal?.aborted)
|
|
85
|
+
const finished = now()
|
|
86
|
+
let decision: ReturnType<typeof decide>
|
|
87
|
+
if (suite) {
|
|
88
|
+
const outcomes: CheckOutcome[] = suite.results.map((r) => ({ id: r.case.id, title: r.case.title, status: r.status, summary: r.summary, result: r }))
|
|
89
|
+
outcomes.push({ ...MONITOR_CHECK, status: "passed", summary: "" })
|
|
90
|
+
decision = decide(prev, outcomes, finished, policy)
|
|
91
|
+
} else {
|
|
92
|
+
const status = stopped ? "stopped" : "error"
|
|
93
|
+
decision = decide(prev, [{ ...MONITOR_CHECK, status, summary: `The round could not run: ${error}` }], finished, policy, true)
|
|
94
|
+
}
|
|
95
|
+
writeState(m.outDir, decision.state)
|
|
96
|
+
|
|
97
|
+
let sent: Sent[] = []
|
|
98
|
+
if (decision.alerts.length) {
|
|
99
|
+
const c = suite ? counts(suite.results) : undefined
|
|
100
|
+
const message: AlertMessage = {
|
|
101
|
+
title: alertTitle(decision.alerts),
|
|
102
|
+
items: decision.alerts,
|
|
103
|
+
at: at.toISOString(),
|
|
104
|
+
passed: c?.passed ?? 0,
|
|
105
|
+
total: c?.total ?? 0,
|
|
106
|
+
...reportLinks(config, suite),
|
|
107
|
+
}
|
|
108
|
+
sent = await (opts.send || sendAlerts)(m.alerts, message)
|
|
109
|
+
if (suite) writeFileSync(path.join(suite.dir, "alerts.json"), `${JSON.stringify({ title: message.title, alerts: decision.alerts.map(({ result, ...a }) => a), sent }, null, 2)}\n`)
|
|
110
|
+
}
|
|
111
|
+
pruneRounds(m.outDir, m.keep)
|
|
112
|
+
const failing = Object.entries(decision.state.checks)
|
|
113
|
+
.filter(([, s]) => s.status === "failing")
|
|
114
|
+
.map(([id, s]) => ({ id, ...s }))
|
|
115
|
+
return { at: at.toISOString(), suite, error, alerts: decision.alerts, sent, failing, failAfter: m.failAfter, stopped }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Delete the oldest round folders (those with a suite.json) beyond `keep`. */
|
|
119
|
+
export function pruneRounds(outDir: string, keep: number): string[] {
|
|
120
|
+
if (!existsSync(outDir)) return []
|
|
121
|
+
const rounds = readdirSync(outDir)
|
|
122
|
+
.filter((n) => existsSync(path.join(outDir, n, "suite.json")))
|
|
123
|
+
.sort()
|
|
124
|
+
const old = rounds.slice(0, Math.max(0, rounds.length - keep))
|
|
125
|
+
for (const n of old) rmSync(path.join(outDir, n), { recursive: true, force: true })
|
|
126
|
+
return old
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 0 when every check passed, 2 when stopped, otherwise 1. */
|
|
130
|
+
export function roundExitCode(r: RoundResult): number {
|
|
131
|
+
if (r.stopped) return 2
|
|
132
|
+
if (!r.suite) return 1
|
|
133
|
+
return r.suite.results.every((x) => x.status === "passed") ? 0 : 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One monitor per folder: a second one (or an overlapping cron run) would race on state.json. */
|
|
137
|
+
export function lockMonitor(outDir: string): () => void {
|
|
138
|
+
mkdirSync(outDir, { recursive: true })
|
|
139
|
+
const file = path.join(outDir, ".monitor.lock")
|
|
140
|
+
const take = () => writeFileSync(file, String(process.pid), { flag: "wx" })
|
|
141
|
+
try {
|
|
142
|
+
take()
|
|
143
|
+
} catch {
|
|
144
|
+
const pid = Number(readFileSync(file, "utf8").trim())
|
|
145
|
+
let alive = false
|
|
146
|
+
try {
|
|
147
|
+
alive = pid > 0 && pid !== process.pid && process.kill(pid, 0)
|
|
148
|
+
} catch {}
|
|
149
|
+
if (alive) throw new Error(`Another forgeqe monitor (pid ${pid}) is using ${outDir}. Stop it, or set monitor.out_dir to another folder.`)
|
|
150
|
+
unlinkSync(file)
|
|
151
|
+
take()
|
|
152
|
+
}
|
|
153
|
+
return () => {
|
|
154
|
+
try {
|
|
155
|
+
if (readFileSync(file, "utf8").trim() === String(process.pid)) unlinkSync(file)
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
if (signal?.aborted) return resolve()
|
|
163
|
+
const timer = setTimeout(done, ms)
|
|
164
|
+
function done() {
|
|
165
|
+
clearTimeout(timer)
|
|
166
|
+
signal?.removeEventListener("abort", done)
|
|
167
|
+
resolve()
|
|
168
|
+
}
|
|
169
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run rounds until stopped: each starts monitor.every after the previous one started, or straight after it when a
|
|
175
|
+
* round overruns. With `once`, one round and its exit code.
|
|
176
|
+
*/
|
|
177
|
+
export async function runMonitor(opts: MonitorOptions & { once?: boolean; onRound?: (r: RoundResult, nextAt?: Date) => void; wait?: typeof sleep }): Promise<number> {
|
|
178
|
+
const unlock = lockMonitor(opts.config.monitor.outDir)
|
|
179
|
+
const wait = opts.wait || sleep
|
|
180
|
+
try {
|
|
181
|
+
if (opts.once) {
|
|
182
|
+
const r = await monitorRound(opts)
|
|
183
|
+
opts.onRound?.(r)
|
|
184
|
+
return roundExitCode(r)
|
|
185
|
+
}
|
|
186
|
+
while (!opts.signal?.aborted) {
|
|
187
|
+
const start = Date.now()
|
|
188
|
+
const r = await monitorRound(opts)
|
|
189
|
+
if (opts.signal?.aborted) {
|
|
190
|
+
opts.onRound?.(r)
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
const nextAt = new Date(Math.max(Date.now(), start + opts.config.monitor.everyMs))
|
|
194
|
+
opts.onRound?.(r, nextAt)
|
|
195
|
+
await wait(nextAt.getTime() - Date.now(), opts.signal)
|
|
196
|
+
}
|
|
197
|
+
return 0
|
|
198
|
+
} finally {
|
|
199
|
+
unlock()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const CHANNEL = { teams: "Teams", email: "email" } as const
|
|
204
|
+
|
|
205
|
+
function hhmm(iso: string | Date): string {
|
|
206
|
+
const d = new Date(iso)
|
|
207
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A round in the terminal: the result line, what changed, alerts sent, the report, and when the next round is. */
|
|
211
|
+
export function formatRound(r: RoundResult, nextAt?: Date, cwd = process.cwd()): string {
|
|
212
|
+
const lines: string[] = []
|
|
213
|
+
if (!r.suite) lines.push(`[${hhmm(r.at)}] ${r.stopped ? "■ Stopped" : `! The round could not run: ${r.error}`}`)
|
|
214
|
+
else {
|
|
215
|
+
const c = counts(r.suite.results)
|
|
216
|
+
const tokens = r.suite.results.reduce((n, x) => n + x.tokens, 0)
|
|
217
|
+
const mark = r.stopped ? "■" : c.passed === c.total ? "✓" : "✗"
|
|
218
|
+
lines.push(`[${hhmm(r.at)}] ${mark} ${c.passed}/${c.total} passing · ${formatDuration(r.suite.ms)} · ${formatTokens(tokens)} tokens${r.stopped ? " · stopped" : ""}`)
|
|
219
|
+
}
|
|
220
|
+
for (const a of r.alerts) {
|
|
221
|
+
lines.push(` ${itemLine(a, r.at)}`)
|
|
222
|
+
const detail = a.kind === "recovered" ? "" : a.summary.split("\n").find((l) => l.trim())
|
|
223
|
+
if (detail) lines.push(` ${detail.trim().slice(0, 200)}`)
|
|
224
|
+
}
|
|
225
|
+
const alerted = new Set(r.alerts.map((a) => a.id))
|
|
226
|
+
for (const f of r.failing.filter((f) => !alerted.has(f.id))) {
|
|
227
|
+
const why = f.alertedAt ? "already alerted" : `alert after ${r.failAfter} failing rounds`
|
|
228
|
+
lines.push(` ✗ ${f.id} ${f.title}: failing since ${hhmm(f.since)} (${f.failures} ${f.failures === 1 ? "round" : "rounds"}, ${why})`)
|
|
229
|
+
}
|
|
230
|
+
if (r.alerts.length && !r.sent.length) lines.push(" No alert channels are set up (monitor.alerts in forgeqe.yaml), so nobody was told.")
|
|
231
|
+
const ok = r.sent.filter((s) => s.ok).map((s) => CHANNEL[s.channel])
|
|
232
|
+
if (ok.length) lines.push(` ↑ Alert sent: ${ok.join(", ")}`)
|
|
233
|
+
for (const s of r.sent.filter((s) => !s.ok)) lines.push(` ✗ ${CHANNEL[s.channel]} alert not sent: ${s.error}`)
|
|
234
|
+
if (r.suite) {
|
|
235
|
+
const total = suiteUsage(r.suite.results)
|
|
236
|
+
if (total.usage.input || total.usage.output) lines.push(` Usage ${usageLine(total.usage, total.cost)}`)
|
|
237
|
+
lines.push(` Report ${path.relative(cwd, r.suite.files.html) || r.suite.files.html}`)
|
|
238
|
+
}
|
|
239
|
+
if (nextAt) lines.push(` Next round at ${hhmm(nextAt)}`)
|
|
240
|
+
return lines.join("\n")
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A round as a card for the shell: the same lines as formatRound under a title. */
|
|
244
|
+
export function roundCard(r: RoundResult, nextAt?: Date, cwd = process.cwd()): Card {
|
|
245
|
+
const [, ...rest] = formatRound(r, nextAt, cwd).split("\n")
|
|
246
|
+
const body = rest.map((l) => l.replace(/^ {4}/, ""))
|
|
247
|
+
if (!r.suite) return { status: r.stopped ? "stopped" : "fail", title: r.stopped ? "Monitor round stopped" : "Monitor round could not run", stats: hhmm(r.at), body: r.stopped ? body : [r.error || "", ...body] }
|
|
248
|
+
const c = counts(r.suite.results)
|
|
249
|
+
const tokens = r.suite.results.reduce((n, x) => n + x.tokens, 0)
|
|
250
|
+
return {
|
|
251
|
+
status: r.stopped ? "stopped" : c.passed === c.total ? "ok" : "fail",
|
|
252
|
+
title: `Monitor: ${c.passed}/${c.total} checks passing`,
|
|
253
|
+
stats: `${hhmm(r.at)} · ${formatDuration(r.suite.ms)} · ${formatTokens(tokens)} tokens`,
|
|
254
|
+
body,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A sample alert through every configured channel, to check the webhook and SMTP settings. */
|
|
259
|
+
export async function sendTestAlert(config: AppConfig, send: typeof sendAlerts = sendAlerts): Promise<Sent[]> {
|
|
260
|
+
const at = new Date().toISOString()
|
|
261
|
+
const item: AlertItem = { kind: "failing", id: "EXAMPLE-1", title: "Sample check", since: at, failures: 1, summary: "This is a test alert from forgeqe monitor --test-alerts. No check failed." }
|
|
262
|
+
return send(config.monitor.alerts, { title: "test alert", items: [item], at, passed: 0, total: 1, test: true })
|
|
263
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { CaseResult, CaseStatus } from "../cases/types.ts"
|
|
5
|
+
|
|
6
|
+
/** What the monitor remembers about a check between rounds. */
|
|
7
|
+
export type CheckState = {
|
|
8
|
+
title: string
|
|
9
|
+
status: "passing" | "failing"
|
|
10
|
+
/** When it started passing or failing. */
|
|
11
|
+
since: string
|
|
12
|
+
/** Failing rounds in a row. */
|
|
13
|
+
failures: number
|
|
14
|
+
/** When the last alert about this failure went out. */
|
|
15
|
+
alertedAt?: string
|
|
16
|
+
summary?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type MonitorState = { checks: Record<string, CheckState>; updatedAt?: string }
|
|
20
|
+
|
|
21
|
+
export type AlertKind = "failing" | "still_failing" | "recovered"
|
|
22
|
+
|
|
23
|
+
export type AlertItem = {
|
|
24
|
+
kind: AlertKind
|
|
25
|
+
id: string
|
|
26
|
+
title: string
|
|
27
|
+
/** When the check started failing (for recovered: how long it was down). */
|
|
28
|
+
since: string
|
|
29
|
+
failures: number
|
|
30
|
+
summary: string
|
|
31
|
+
result?: CaseResult
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CheckOutcome = { id: string; title: string; status: CaseStatus; summary: string; result?: CaseResult }
|
|
35
|
+
|
|
36
|
+
export type AlertPolicy = { failAfter: number; remindMs: number }
|
|
37
|
+
|
|
38
|
+
/** The pseudo-check that fails when a round cannot run at all (a source is down, the engine will not start). */
|
|
39
|
+
export const MONITOR_CHECK = { id: "forgeqe-monitor", title: "ForgeQE monitor" }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The next state and the alerts one round's outcomes call for: a check's first alert once it has failed `failAfter`
|
|
43
|
+
* rounds in a row, a reminder every `remindMs` while it keeps failing, and a recovery once it passes after an alert.
|
|
44
|
+
* Stopped checks change nothing. `keepOthers` keeps checks this round did not report (a round that could not run).
|
|
45
|
+
*/
|
|
46
|
+
export function decide(prev: MonitorState, outcomes: CheckOutcome[], now: Date, policy: AlertPolicy, keepOthers = false): { state: MonitorState; alerts: AlertItem[] } {
|
|
47
|
+
const at = now.toISOString()
|
|
48
|
+
const checks: Record<string, CheckState> = keepOthers ? { ...prev.checks } : {}
|
|
49
|
+
const alerts: AlertItem[] = []
|
|
50
|
+
for (const o of outcomes) {
|
|
51
|
+
const p = prev.checks[o.id]
|
|
52
|
+
if (o.status === "stopped") {
|
|
53
|
+
if (p) checks[o.id] = p
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (o.status === "failed" || o.status === "error") {
|
|
57
|
+
const ongoing = p?.status === "failing"
|
|
58
|
+
const next: CheckState = {
|
|
59
|
+
title: o.title,
|
|
60
|
+
status: "failing",
|
|
61
|
+
since: ongoing ? p.since : at,
|
|
62
|
+
failures: ongoing ? p.failures + 1 : 1,
|
|
63
|
+
...(ongoing && p.alertedAt ? { alertedAt: p.alertedAt } : {}),
|
|
64
|
+
summary: o.summary,
|
|
65
|
+
}
|
|
66
|
+
const item = { id: o.id, title: o.title, since: next.since, failures: next.failures, summary: o.summary, result: o.result }
|
|
67
|
+
if (!next.alertedAt && next.failures >= policy.failAfter) {
|
|
68
|
+
alerts.push({ kind: "failing", ...item })
|
|
69
|
+
next.alertedAt = at
|
|
70
|
+
} else if (next.alertedAt && policy.remindMs > 0 && now.getTime() - Date.parse(next.alertedAt) >= policy.remindMs) {
|
|
71
|
+
alerts.push({ kind: "still_failing", ...item })
|
|
72
|
+
next.alertedAt = at
|
|
73
|
+
}
|
|
74
|
+
checks[o.id] = next
|
|
75
|
+
} else {
|
|
76
|
+
if (p?.status === "failing" && p.alertedAt) {
|
|
77
|
+
alerts.push({ kind: "recovered", id: o.id, title: o.title, since: p.since, failures: p.failures, summary: o.summary, result: o.result })
|
|
78
|
+
}
|
|
79
|
+
checks[o.id] = { title: o.title, status: "passing", since: p?.status === "passing" ? p.since : at, failures: 0 }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { state: { checks, updatedAt: at }, alerts }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function stateFile(outDir: string): string {
|
|
86
|
+
return path.join(outDir, "state.json")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readState(outDir: string): MonitorState {
|
|
90
|
+
const file = stateFile(outDir)
|
|
91
|
+
if (!existsSync(file)) return { checks: {} }
|
|
92
|
+
try {
|
|
93
|
+
const data = JSON.parse(readFileSync(file, "utf8")) as MonitorState
|
|
94
|
+
return data && typeof data.checks === "object" ? data : { checks: {} }
|
|
95
|
+
} catch {
|
|
96
|
+
return { checks: {} }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function writeState(outDir: string, state: MonitorState) {
|
|
101
|
+
mkdirSync(outDir, { recursive: true })
|
|
102
|
+
const file = stateFile(outDir)
|
|
103
|
+
writeFileSync(`${file}.tmp`, `${JSON.stringify(state, null, 2)}\n`)
|
|
104
|
+
renameSync(`${file}.tmp`, file)
|
|
105
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import os from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { AppConfig, NotifyEvent } from "../config.ts"
|
|
5
|
+
import { FAILURE_LABEL } from "../cases/failure.ts"
|
|
6
|
+
import type { Readiness } from "../cases/readiness.ts"
|
|
7
|
+
import type { EvalReport } from "../evals/domain.ts"
|
|
8
|
+
import { ciInfo, duration } from "../report/html.ts"
|
|
9
|
+
import { counts } from "../suite/report.ts"
|
|
10
|
+
import type { SuiteResult } from "../suite/run.ts"
|
|
11
|
+
import { failureShot, sendNotice, type Link, type Notice, type NoticeItem, type SendDeps, type Sent } from "./notice.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Notifications the team gets without asking (notify: in forgeqe.yaml): each channel names the events it wants, and
|
|
15
|
+
* every event becomes the same kind of card in Teams and message in email.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MAX_CASES = 10
|
|
19
|
+
const MAX_SHOTS = 5
|
|
20
|
+
|
|
21
|
+
export type Notified = { channel: string; sent: Sent[] }
|
|
22
|
+
|
|
23
|
+
/** Send `n` to every channel that wants `event`; the result says, per channel, what went out and what failed. */
|
|
24
|
+
export async function notify(cfg: Pick<AppConfig, "notify">, event: NotifyEvent, n: Notice, deps: SendDeps = {}): Promise<Notified[]> {
|
|
25
|
+
const channels = (cfg.notify?.channels || []).filter((c) => c.on.includes(event))
|
|
26
|
+
return Promise.all(channels.map(async (c) => ({ channel: c.name, sent: await sendNotice(c, n, deps) })))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A finished suite to the channels that want it: suite_passed or suite_failed. A stopped suite is not announced. */
|
|
30
|
+
export function notifySuite(cfg: Pick<AppConfig, "notify" | "export">, s: SuiteResult, deps: SendDeps = {}): Promise<Notified[]> {
|
|
31
|
+
if (s.stopped || !cfg.notify) return Promise.resolve([])
|
|
32
|
+
const n = suiteNotice(s, cfg)
|
|
33
|
+
return notify(cfg, n.status === "good" ? "suite_passed" : "suite_failed", n, deps)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "Notified qa (Teams), leads (email failed: …)": one line for a terminal or a tool result. */
|
|
37
|
+
export function notifiedLine(list: Notified[]): string {
|
|
38
|
+
if (!list.length) return ""
|
|
39
|
+
const label = (c: Sent["channel"]) => (c === "teams" ? "Teams" : "email")
|
|
40
|
+
const each = list.map((n) => `${n.channel} (${n.sent.map((s) => (s.ok ? label(s.channel) : `${label(s.channel)} failed: ${s.error}`)).join(", ")})`)
|
|
41
|
+
return `${list.every((n) => n.sent.every((s) => s.ok)) ? "Notified" : "Notifications"} ${each.join("; ")}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where a report can be opened by whoever reads the card: under notify.report_url when the reports folder is
|
|
46
|
+
* published there, and the CI build's page when running in a pipeline. Otherwise only its path, as a note.
|
|
47
|
+
*/
|
|
48
|
+
export function reportLinks(cfg: Pick<AppConfig, "notify" | "export">, file?: string, env = process.env): { actions: Link[]; note?: string } {
|
|
49
|
+
const actions: Link[] = []
|
|
50
|
+
const base = cfg.notify?.reportUrl
|
|
51
|
+
if (base && file) {
|
|
52
|
+
const rel = path.relative(cfg.export.outDir, file)
|
|
53
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) actions.push({ title: "Open report", url: `${base}/${rel.split(path.sep).map(encodeURIComponent).join("/")}` })
|
|
54
|
+
}
|
|
55
|
+
const ci = ciInfo(env).ci
|
|
56
|
+
if (ci?.url) actions.push({ title: "Open build", url: ci.url })
|
|
57
|
+
return { actions, ...(!actions.some((a) => a.title === "Open report") && file ? { note: `Report: ${file}` } : {}) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function where(env = process.env): string {
|
|
61
|
+
const info = ciInfo(env)
|
|
62
|
+
return [info.ci?.name || `on ${os.hostname()}`, info.branch].filter(Boolean).join(" · ")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const firstLine = (s: string, max = 240) => {
|
|
66
|
+
const line = (s.split("\n").find((l) => l.trim()) || "").trim()
|
|
67
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}…`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "functional defect" → "Functional defect", for the start of a line. */
|
|
71
|
+
const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
|
|
72
|
+
|
|
73
|
+
export function suiteNotice(s: SuiteResult, cfg: Pick<AppConfig, "notify" | "export">, env = process.env): Notice {
|
|
74
|
+
const c = counts(s.results)
|
|
75
|
+
const notPassed = s.results.filter((r) => r.status !== "passed")
|
|
76
|
+
const testsFailed = s.results.filter((r) => r.status === "passed" && r.code && r.code.status !== "passed")
|
|
77
|
+
const ok = !s.stopped && !notPassed.length && !testsFailed.length
|
|
78
|
+
// A suite named by default ("ForgeQE suite <time>") would repeat the card's source.
|
|
79
|
+
const name = s.name.replace(/^ForgeQE suite\b/, "Suite")
|
|
80
|
+
const title = s.stopped
|
|
81
|
+
? `${name} was stopped: ${c.passed}/${c.total} passed`
|
|
82
|
+
: ok
|
|
83
|
+
? `${name} passed: ${c.passed}/${c.total}`
|
|
84
|
+
: `${name} failed: ${notPassed.length} of ${c.total} cases did not pass`
|
|
85
|
+
const bugs = (id: string) => s.writeBack.filter((w) => w.source === "bugs" && w.caseId === id && w.url).map((w) => ({ title: "Bug", url: w.url! }))
|
|
86
|
+
const items: NoticeItem[] = notPassed.slice(0, MAX_CASES).map((r) => ({
|
|
87
|
+
line: `${r.status === "failed" ? "✗" : r.status === "error" ? "!" : "■"} ${r.case.id} ${r.case.title}`,
|
|
88
|
+
status: r.status === "failed" ? "bad" : "warn",
|
|
89
|
+
details: [r.failure ? `${sentence(FAILURE_LABEL[r.failure.type])}: ${firstLine(r.failure.reason)}` : firstLine(r.summary)].filter(Boolean),
|
|
90
|
+
links: [...(r.case.url ? [{ title: "Test case", url: r.case.url }] : []), ...bugs(r.case.id)],
|
|
91
|
+
}))
|
|
92
|
+
if (notPassed.length > MAX_CASES) items.push({ line: `…and ${notPassed.length - MAX_CASES} more` })
|
|
93
|
+
for (const r of testsFailed.slice(0, MAX_CASES)) {
|
|
94
|
+
items.push({ line: `✗ ${r.case.id} ${r.case.title}: the case passed, but its automated test did not`, status: "bad", details: [firstLine(r.code?.error || "")].filter(Boolean) })
|
|
95
|
+
}
|
|
96
|
+
if (c.coded) items.push({ line: `Tests written: ${c.codePassed}/${c.coded} passing`, status: c.codePassed === c.coded ? "good" : "warn" })
|
|
97
|
+
const links = reportLinks(cfg, s.files.html, env)
|
|
98
|
+
return {
|
|
99
|
+
source: "ForgeQE suite",
|
|
100
|
+
title,
|
|
101
|
+
status: s.stopped ? "warn" : ok ? "good" : "bad",
|
|
102
|
+
subtitle: `${duration(s.ms)} · ${where(env)}`,
|
|
103
|
+
items,
|
|
104
|
+
actions: links.actions,
|
|
105
|
+
...(links.note ? { note: links.note } : {}),
|
|
106
|
+
attachments: notPassed
|
|
107
|
+
.map((r) => failureShot(r.runDir, r.case.id))
|
|
108
|
+
.filter((a): a is { filename: string; path: string } => Boolean(a))
|
|
109
|
+
.slice(0, MAX_SHOTS),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function readinessNotice(r: Readiness, cfg: Pick<AppConfig, "notify" | "export">, opts: { file?: string; published?: string } = {}, env = process.env): Notice {
|
|
114
|
+
const c = r.counts
|
|
115
|
+
const links = reportLinks(cfg, opts.file, env)
|
|
116
|
+
return {
|
|
117
|
+
source: "ForgeQE release readiness",
|
|
118
|
+
title: `${r.verdict} · ${r.scope}`,
|
|
119
|
+
status: r.verdict === "GO" ? "good" : r.verdict === "NO-GO" ? "bad" : "warn",
|
|
120
|
+
subtitle: `${c.stories} stories · ${c.cases} cases, ${c.executed} run, ${c.passed} passed${r.passRate === null ? "" : ` (${Math.round(r.passRate * 100)}%)`} · ${r.bugs.length} open bugs`,
|
|
121
|
+
items: [
|
|
122
|
+
...r.blockers.map((b) => ({ line: `Blocker: ${b}`, status: "bad" as const })),
|
|
123
|
+
...r.risks.map((b) => ({ line: `Risk: ${b}`, status: "warn" as const })),
|
|
124
|
+
],
|
|
125
|
+
actions: [...(opts.published ? [{ title: "Open wiki page", url: opts.published }] : []), ...links.actions],
|
|
126
|
+
...(links.note && !opts.published ? { note: links.note } : {}),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function evalNotice(r: EvalReport, failed: string, cfg: Pick<AppConfig, "notify" | "export">, file?: string, env = process.env): Notice {
|
|
131
|
+
const links = reportLinks(cfg, file, env)
|
|
132
|
+
const pct = (x: { covered: number; total: number }) => (x.total ? `${Math.round((100 * x.covered) / x.total)}%` : "–")
|
|
133
|
+
return {
|
|
134
|
+
source: "ForgeQE evaluation",
|
|
135
|
+
title: `Domain knowledge below the gate: ${failed}`,
|
|
136
|
+
status: "bad",
|
|
137
|
+
subtitle: `${r.runs.length} runs · agent ${r.model || "?"} · ${where(env)}`,
|
|
138
|
+
items: (Object.keys(r.summary) as (keyof EvalReport["summary"])[]).map((v) => ({
|
|
139
|
+
line: `${v}: domain ${pct(r.summary[v].coverage.domain)} · regulation ${pct(r.summary[v].coverage.regulation)} · ${r.summary[v].invented} invented ids`,
|
|
140
|
+
})),
|
|
141
|
+
actions: links.actions,
|
|
142
|
+
...(links.note ? { note: links.note } : {}),
|
|
143
|
+
}
|
|
144
|
+
}
|