@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
package/src/memory.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Readers for the engine's page memory (pages/<host>/*.json, flows/*.json) behind `forgeqe memory` and /memory. */
|
|
5
|
+
|
|
6
|
+
export type MemoryLocator = {
|
|
7
|
+
type: string
|
|
8
|
+
value: string
|
|
9
|
+
playwright: string
|
|
10
|
+
confidence: number
|
|
11
|
+
seen?: number
|
|
12
|
+
missed?: number
|
|
13
|
+
success?: number
|
|
14
|
+
failure?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type MemoryElement = {
|
|
18
|
+
key: string
|
|
19
|
+
tag: string
|
|
20
|
+
role?: string | null
|
|
21
|
+
name?: string | null
|
|
22
|
+
text?: string | null
|
|
23
|
+
attributes?: Record<string, string>
|
|
24
|
+
locators: MemoryLocator[]
|
|
25
|
+
best?: string | null
|
|
26
|
+
confidence: number
|
|
27
|
+
visits?: number
|
|
28
|
+
interactions?: number
|
|
29
|
+
success?: number
|
|
30
|
+
failure?: number
|
|
31
|
+
first_seen?: string
|
|
32
|
+
last_seen?: string
|
|
33
|
+
last_used?: string
|
|
34
|
+
last_updated?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type MemoryPage = {
|
|
38
|
+
host: string
|
|
39
|
+
path: string
|
|
40
|
+
title?: string
|
|
41
|
+
visits?: number
|
|
42
|
+
first_seen?: string
|
|
43
|
+
last_seen?: string
|
|
44
|
+
sample_urls?: string[]
|
|
45
|
+
elements: Record<string, MemoryElement>
|
|
46
|
+
file: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type MemoryFlow = {
|
|
50
|
+
goal: string
|
|
51
|
+
start_url?: string
|
|
52
|
+
host?: string
|
|
53
|
+
runs?: number
|
|
54
|
+
successes?: number
|
|
55
|
+
failures?: number
|
|
56
|
+
replays?: number
|
|
57
|
+
llm_steps?: number
|
|
58
|
+
replayed_steps?: number
|
|
59
|
+
last_success?: string
|
|
60
|
+
last_run?: string
|
|
61
|
+
last_status?: string
|
|
62
|
+
steps: { action: string; line?: string }[]
|
|
63
|
+
file: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readJson<T>(file: string): T | null {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(file, "utf8")) as T
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function jsonFiles(dir: string): string[] {
|
|
75
|
+
if (!existsSync(dir)) return []
|
|
76
|
+
return readdirSync(dir)
|
|
77
|
+
.filter((f) => f.endsWith(".json"))
|
|
78
|
+
.map((f) => path.join(dir, f))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readPages(dir: string): MemoryPage[] {
|
|
82
|
+
const root = path.join(dir, "pages")
|
|
83
|
+
if (!existsSync(root)) return []
|
|
84
|
+
const pages: MemoryPage[] = []
|
|
85
|
+
for (const host of readdirSync(root)) {
|
|
86
|
+
for (const file of jsonFiles(path.join(root, host))) {
|
|
87
|
+
const page = readJson<Omit<MemoryPage, "file">>(file)
|
|
88
|
+
if (page && page.host && page.elements) pages.push({ ...page, file })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return pages
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function readFlows(dir: string): MemoryFlow[] {
|
|
95
|
+
const flows: MemoryFlow[] = []
|
|
96
|
+
for (const file of jsonFiles(path.join(dir, "flows"))) {
|
|
97
|
+
const flow = readJson<Omit<MemoryFlow, "file">>(file)
|
|
98
|
+
if (flow && flow.goal && Array.isArray(flow.steps)) flows.push({ ...flow, file })
|
|
99
|
+
}
|
|
100
|
+
return flows.sort((a, b) => String(b.last_run || "").localeCompare(String(a.last_run || "")))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const when = (iso?: string) => (iso ? iso.slice(0, 16).replace("T", " ") : "—")
|
|
104
|
+
const clip = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
|
|
105
|
+
|
|
106
|
+
function table(rows: string[][], right: number[] = []): string[] {
|
|
107
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)))
|
|
108
|
+
return rows.map((r) =>
|
|
109
|
+
r
|
|
110
|
+
.map((cell, i) => (i === r.length - 1 ? cell : right.includes(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
|
|
111
|
+
.join(" ")
|
|
112
|
+
.trimEnd(),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hostMatches(host: string, target: string): boolean {
|
|
117
|
+
const h = host.toLowerCase()
|
|
118
|
+
const t = target.toLowerCase().replace(/^www\./, "")
|
|
119
|
+
return h === t || h === `www.${t}` || h.endsWith(`.${t}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pathPattern(template: string): RegExp {
|
|
123
|
+
const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]+")
|
|
124
|
+
return new RegExp(`^${escaped}$`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The page file for a URL: same host (and port), and a path that fits the stored template. */
|
|
128
|
+
export function findPage(pages: MemoryPage[], url: string): MemoryPage | undefined {
|
|
129
|
+
let u: URL
|
|
130
|
+
try {
|
|
131
|
+
u = new URL(url.includes("://") ? url : `https://${url}`)
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
const host = u.port ? `${u.hostname}:${u.port}` : u.hostname
|
|
136
|
+
const route = u.hash.startsWith("#/") || u.hash.startsWith("#!/") ? u.hash.split("?")[0] : ""
|
|
137
|
+
const target = (u.pathname.replace(/\/+$/, "") || "/") + route
|
|
138
|
+
const candidates = pages.filter((p) => p.host === host || hostMatches(p.host, u.hostname))
|
|
139
|
+
return candidates.find((p) => p.path === target) || candidates.find((p) => pathPattern(p.path).test(target))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function memoryOverview(dir: string): string {
|
|
143
|
+
const pages = readPages(dir)
|
|
144
|
+
const flows = readFlows(dir)
|
|
145
|
+
if (!pages.length && !flows.length) return `No page memory yet in ${dir}\nIt fills up as live runs visit pages.`
|
|
146
|
+
const hosts = new Map<string, { pages: number; elements: number; flows: number; last: string }>()
|
|
147
|
+
for (const p of pages) {
|
|
148
|
+
const h = hosts.get(p.host) || { pages: 0, elements: 0, flows: 0, last: "" }
|
|
149
|
+
h.pages++
|
|
150
|
+
h.elements += Object.keys(p.elements).length
|
|
151
|
+
if ((p.last_seen || "") > h.last) h.last = p.last_seen || ""
|
|
152
|
+
hosts.set(p.host, h)
|
|
153
|
+
}
|
|
154
|
+
for (const f of flows) {
|
|
155
|
+
const h = hosts.get(f.host || "?") || { pages: 0, elements: 0, flows: 0, last: "" }
|
|
156
|
+
h.flows++
|
|
157
|
+
hosts.set(f.host || "?", h)
|
|
158
|
+
}
|
|
159
|
+
const rows = [["HOST", "PAGES", "ELEMENTS", "FLOWS", "LAST SEEN"]]
|
|
160
|
+
for (const [host, h] of [...hosts].sort((a, b) => b[1].last.localeCompare(a[1].last))) {
|
|
161
|
+
rows.push([host, String(h.pages), String(h.elements), String(h.flows), when(h.last)])
|
|
162
|
+
}
|
|
163
|
+
return [`Memory ${dir}`, "", ...table(rows, [1, 2, 3]), "", "forgeqe memory show <host|url> · forgeqe memory flows"].join("\n")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function elementLabel(e: MemoryElement): string {
|
|
167
|
+
const label = e.name || e.text || e.attributes?.placeholder || e.attributes?.["aria-label"] || ""
|
|
168
|
+
return label ? `${e.tag} "${clip(label, 36)}"` : e.tag
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function pageDetail(page: MemoryPage, opts: { locators: boolean; limit: number }): string {
|
|
172
|
+
const elements = Object.values(page.elements).sort(
|
|
173
|
+
(a, b) => (b.interactions || 0) - (a.interactions || 0) || b.confidence - a.confidence,
|
|
174
|
+
)
|
|
175
|
+
const shown = opts.limit > 0 ? elements.slice(0, opts.limit) : elements
|
|
176
|
+
const lines = [
|
|
177
|
+
`${page.host}${page.path} ${page.title || ""}`.trimEnd(),
|
|
178
|
+
`${page.visits || 0} visits · ${elements.length} elements · first seen ${when(page.first_seen)} · last seen ${when(page.last_seen)}`,
|
|
179
|
+
"",
|
|
180
|
+
]
|
|
181
|
+
const rows = [["CONF", "ELEMENT", "USED", "LAST SEEN", "BEST LOCATOR"]]
|
|
182
|
+
for (const e of shown) {
|
|
183
|
+
const used = e.interactions ? `${e.interactions}× ${e.failure ? `${e.failure} failed` : "ok"}` : "—"
|
|
184
|
+
rows.push([`${e.confidence}%`, elementLabel(e), used, when(e.last_seen), e.best || "—"])
|
|
185
|
+
}
|
|
186
|
+
const body = table(rows, [0])
|
|
187
|
+
if (!opts.locators) {
|
|
188
|
+
lines.push(...body)
|
|
189
|
+
} else {
|
|
190
|
+
lines.push(body[0])
|
|
191
|
+
shown.forEach((e, i) => {
|
|
192
|
+
lines.push(body[i + 1])
|
|
193
|
+
for (const l of e.locators) {
|
|
194
|
+
const evidence = `seen ${l.seen || 0}${l.missed ? `, missed ${l.missed}` : ""}${l.success ? `, ok ${l.success}` : ""}${l.failure ? `, failed ${l.failure}` : ""}`
|
|
195
|
+
lines.push(` ${String(l.confidence).padStart(2)}% ${l.type.padEnd(11)} ${l.playwright} (${evidence})`)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (shown.length < elements.length) lines.push("", `${elements.length - shown.length} more; pass --all to list every element.`)
|
|
200
|
+
return lines.join("\n")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function memoryShow(dir: string, target: string, opts: { locators?: boolean; limit?: number } = {}): string {
|
|
204
|
+
const pages = readPages(dir)
|
|
205
|
+
const detail = { locators: Boolean(opts.locators), limit: opts.limit ?? 40 }
|
|
206
|
+
const looksLikeUrl = target.includes("://") || target.includes("/")
|
|
207
|
+
if (looksLikeUrl) {
|
|
208
|
+
const page = findPage(pages, target)
|
|
209
|
+
return page ? pageDetail(page, detail) : `No memory for ${target} yet.`
|
|
210
|
+
}
|
|
211
|
+
const onHost = pages.filter((p) => hostMatches(p.host, target))
|
|
212
|
+
if (!onHost.length) return `No memory for ${target} yet.`
|
|
213
|
+
if (onHost.length === 1) return pageDetail(onHost[0], detail)
|
|
214
|
+
const rows = [["PATH", "VISITS", "ELEMENTS", "LAST SEEN", "TITLE"]]
|
|
215
|
+
for (const p of onHost.sort((a, b) => (b.visits || 0) - (a.visits || 0))) {
|
|
216
|
+
rows.push([p.path, String(p.visits || 0), String(Object.keys(p.elements).length), when(p.last_seen), clip(p.title || "", 50)])
|
|
217
|
+
}
|
|
218
|
+
const host = onHost[0].host
|
|
219
|
+
return [`${host} (${onHost.length} pages)`, "", ...table(rows, [1, 2]), "", `forgeqe memory show https://${host}<path> for a page's elements`].join("\n")
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function memoryFlows(dir: string, opts: { steps?: boolean } = {}): string {
|
|
223
|
+
const flows = readFlows(dir)
|
|
224
|
+
if (!flows.length) return "No recorded flows yet. A goal is recorded after its first successful live run."
|
|
225
|
+
const rows = [["PASSED", "STEPS", "REPLAYS", "LAST PASSED", "GOAL"]]
|
|
226
|
+
for (const f of flows) {
|
|
227
|
+
rows.push([`${f.successes || 0}/${f.runs || 0}`, String(f.steps.length), String(f.replays || 0), when(f.last_success), clip(f.goal, 70)])
|
|
228
|
+
}
|
|
229
|
+
const lines = table(rows, [1, 2])
|
|
230
|
+
if (opts.steps) {
|
|
231
|
+
for (const f of flows) {
|
|
232
|
+
lines.push("", f.goal)
|
|
233
|
+
f.steps.forEach((s, i) => lines.push(` ${String(i + 1).padStart(2)}. ${s.line || s.action}`))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return lines.join("\n")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Files that `clear` would delete: everything, or one host's pages and flows. */
|
|
240
|
+
export function memoryFilesFor(dir: string, host?: string): string[] {
|
|
241
|
+
if (!host) return [...readPages(dir).map((p) => p.file), ...readFlows(dir).map((f) => f.file)]
|
|
242
|
+
return [
|
|
243
|
+
...readPages(dir).filter((p) => hostMatches(p.host, host)).map((p) => p.file),
|
|
244
|
+
...readFlows(dir).filter((f) => f.host && hostMatches(f.host, host)).map((f) => f.file),
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function clearMemory(files: string[]): void {
|
|
249
|
+
for (const file of files) rmSync(file, { force: true })
|
|
250
|
+
}
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Region (ISO 3166-1 alpha-2) to the currency people are paid in there. */
|
|
2
|
+
const REGION_CURRENCY: Record<string, string> = {
|
|
3
|
+
AD: "EUR", AE: "AED", AF: "AFN", AG: "XCD", AI: "XCD", AL: "ALL", AM: "AMD", AO: "AOA", AR: "ARS", AS: "USD", AT: "EUR", AU: "AUD", AW: "AWG", AX: "EUR", AZ: "AZN",
|
|
4
|
+
BA: "BAM", BB: "BBD", BD: "BDT", BE: "EUR", BF: "XOF", BG: "EUR", BH: "BHD", BI: "BIF", BJ: "XOF", BL: "EUR", BM: "BMD", BN: "BND", BO: "BOB", BQ: "USD", BR: "BRL", BS: "BSD", BT: "BTN", BW: "BWP", BY: "BYN", BZ: "BZD",
|
|
5
|
+
CA: "CAD", CC: "AUD", CD: "CDF", CF: "XAF", CG: "XAF", CH: "CHF", CI: "XOF", CK: "NZD", CL: "CLP", CM: "XAF", CN: "CNY", CO: "COP", CR: "CRC", CU: "CUP", CV: "CVE", CW: "ANG", CX: "AUD", CY: "EUR", CZ: "CZK",
|
|
6
|
+
DE: "EUR", DJ: "DJF", DK: "DKK", DM: "XCD", DO: "DOP", DZ: "DZD", EC: "USD", EE: "EUR", EG: "EGP", EH: "MAD", ER: "ERN", ES: "EUR", ET: "ETB", FI: "EUR", FJ: "FJD", FK: "FKP", FM: "USD", FO: "DKK", FR: "EUR",
|
|
7
|
+
GA: "XAF", GB: "GBP", GD: "XCD", GE: "GEL", GF: "EUR", GG: "GBP", GH: "GHS", GI: "GIP", GL: "DKK", GM: "GMD", GN: "GNF", GP: "EUR", GQ: "XAF", GR: "EUR", GT: "GTQ", GU: "USD", GW: "XOF", GY: "GYD",
|
|
8
|
+
HK: "HKD", HN: "HNL", HR: "EUR", HT: "HTG", HU: "HUF", ID: "IDR", IE: "EUR", IL: "ILS", IM: "GBP", IN: "INR", IO: "USD", IQ: "IQD", IR: "IRR", IS: "ISK", IT: "EUR", JE: "GBP", JM: "JMD", JO: "JOD", JP: "JPY",
|
|
9
|
+
KE: "KES", KG: "KGS", KH: "KHR", KI: "AUD", KM: "KMF", KN: "XCD", KP: "KPW", KR: "KRW", KW: "KWD", KY: "KYD", KZ: "KZT", LA: "LAK", LB: "LBP", LC: "XCD", LI: "CHF", LK: "LKR", LR: "LRD", LS: "LSL", LT: "EUR", LU: "EUR", LV: "EUR", LY: "LYD",
|
|
10
|
+
MA: "MAD", MC: "EUR", MD: "MDL", ME: "EUR", MF: "EUR", MG: "MGA", MH: "USD", MK: "MKD", ML: "XOF", MM: "MMK", MN: "MNT", MO: "MOP", MP: "USD", MQ: "EUR", MR: "MRU", MS: "XCD", MT: "EUR", MU: "MUR", MV: "MVR", MW: "MWK", MX: "MXN", MY: "MYR", MZ: "MZN",
|
|
11
|
+
NA: "NAD", NC: "XPF", NE: "XOF", NF: "AUD", NG: "NGN", NI: "NIO", NL: "EUR", NO: "NOK", NP: "NPR", NR: "AUD", NU: "NZD", NZ: "NZD", OM: "OMR", PA: "PAB", PE: "PEN", PF: "XPF", PG: "PGK", PH: "PHP", PK: "PKR", PL: "PLN", PM: "EUR", PN: "NZD", PR: "USD", PS: "ILS", PT: "EUR", PW: "USD", PY: "PYG",
|
|
12
|
+
QA: "QAR", RE: "EUR", RO: "RON", RS: "RSD", RU: "RUB", RW: "RWF", SA: "SAR", SB: "SBD", SC: "SCR", SD: "SDG", SE: "SEK", SG: "SGD", SH: "SHP", SI: "EUR", SJ: "NOK", SK: "EUR", SL: "SLE", SM: "EUR", SN: "XOF", SO: "SOS", SR: "SRD", SS: "SSP", ST: "STN", SV: "USD", SX: "ANG", SY: "SYP", SZ: "SZL",
|
|
13
|
+
TC: "USD", TD: "XAF", TG: "XOF", TH: "THB", TJ: "TJS", TK: "NZD", TL: "USD", TM: "TMT", TN: "TND", TO: "TOP", TR: "TRY", TT: "TTD", TV: "AUD", TW: "TWD", TZ: "TZS", UA: "UAH", UG: "UGX", US: "USD", UY: "UYU", UZ: "UZS", VA: "EUR", VC: "XCD", VE: "VES", VG: "USD", VI: "USD", VN: "VND", VU: "VUV",
|
|
14
|
+
WF: "XPF", WS: "WST", YE: "YER", YT: "EUR", ZA: "ZAR", ZM: "ZMW", ZW: "ZWG",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** ISO 4217 currency for a locale (en-GB → GBP). Unknown regions fall back to USD. */
|
|
18
|
+
export function currencyForLocale(locale?: string): string {
|
|
19
|
+
const tag = locale || Intl.DateTimeFormat().resolvedOptions().locale || "en-US"
|
|
20
|
+
try {
|
|
21
|
+
const region = new Intl.Locale(tag).maximize().region
|
|
22
|
+
if (region && REGION_CURRENCY[region]) return REGION_CURRENCY[region]
|
|
23
|
+
} catch {
|
|
24
|
+
/* not a locale tag */
|
|
25
|
+
}
|
|
26
|
+
return "USD"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An ISO currency code Intl can format, or undefined. */
|
|
30
|
+
export function currencyCode(raw: string): string | undefined {
|
|
31
|
+
const code = raw.trim().toUpperCase()
|
|
32
|
+
if (!/^[A-Z]{3}$/.test(code)) return undefined
|
|
33
|
+
try {
|
|
34
|
+
Intl.NumberFormat("en", { style: "currency", currency: code }).format(0)
|
|
35
|
+
return code
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MonitorAlerts } from "../config.ts"
|
|
2
|
+
import { failureShot, noticeEmail, sendNotice, teamsCard, type Notice, type SendDeps, type Sent } from "../notify/notice.ts"
|
|
3
|
+
import type { AlertItem } from "./state.ts"
|
|
4
|
+
|
|
5
|
+
export type { Sent }
|
|
6
|
+
|
|
7
|
+
/** One alert per round, covering every check that started failing, is still failing, or recovered. */
|
|
8
|
+
export type AlertMessage = {
|
|
9
|
+
title: string
|
|
10
|
+
items: AlertItem[]
|
|
11
|
+
/** The round: when it ran, how many checks passed, and where its report is. */
|
|
12
|
+
at: string
|
|
13
|
+
passed: number
|
|
14
|
+
total: number
|
|
15
|
+
report?: string
|
|
16
|
+
reportUrl?: string
|
|
17
|
+
test?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ATTACHMENTS = 5
|
|
21
|
+
|
|
22
|
+
function firstLine(s: string, max = 300): string {
|
|
23
|
+
const line = s.split("\n").find((l) => l.trim()) || ""
|
|
24
|
+
return line.length <= max ? line.trim() : `${line.trim().slice(0, max - 1)}…`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clock(iso: string): string {
|
|
28
|
+
const d = new Date(iso)
|
|
29
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "45m", "2h 10m", "3d 4h": how long since `from`. */
|
|
33
|
+
export function elapsed(from: string, to: string): string {
|
|
34
|
+
const mins = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 60_000))
|
|
35
|
+
if (mins < 60) return `${mins}m`
|
|
36
|
+
const h = Math.floor(mins / 60)
|
|
37
|
+
if (h < 24) return `${h}h${mins % 60 ? ` ${mins % 60}m` : ""}`
|
|
38
|
+
return `${Math.floor(h / 24)}d${h % 24 ? ` ${h % 24}h` : ""}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const failing = (i: AlertItem) => i.kind !== "recovered"
|
|
42
|
+
|
|
43
|
+
export function alertTitle(items: AlertItem[]): string {
|
|
44
|
+
const down = items.filter(failing)
|
|
45
|
+
const up = items.filter((i) => !failing(i))
|
|
46
|
+
if (down.length && up.length) return `${down.length} failing, ${up.length} recovered`
|
|
47
|
+
if (down.length) return down.length === 1 ? `${down[0].title} is failing` : `${down.length} checks are failing`
|
|
48
|
+
return up.length === 1 ? `${up[0].title} recovered` : `${up.length} checks recovered`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One line per item, e.g. "✗ SHOP-12 Checkout: failing since 14:15 (3 rounds)". */
|
|
52
|
+
export function itemLine(i: AlertItem, at: string): string {
|
|
53
|
+
const name = `${i.id} ${i.title}`
|
|
54
|
+
if (i.kind === "recovered") return `✓ ${name}: recovered after ${elapsed(i.since, at)}`
|
|
55
|
+
const rounds = i.failures > 1 ? ` (${i.failures} rounds)` : ""
|
|
56
|
+
return `✗ ${name}: ${i.kind === "failing" ? "failing" : "still failing"} since ${clock(i.since)}${rounds}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function itemDetail(i: AlertItem): string[] {
|
|
60
|
+
if (i.kind === "recovered") return []
|
|
61
|
+
const step = i.result?.stepResults?.find((v) => v.status === "failed")
|
|
62
|
+
return [firstLine(i.summary), step ? `Step ${step.step} failed${step.actual ? `: ${firstLine(step.actual, 200)}` : ""}` : ""].filter(Boolean)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The round as a notice: the same card and email every other ForgeQE notification uses. */
|
|
66
|
+
export function monitorNotice(m: AlertMessage): Notice {
|
|
67
|
+
return {
|
|
68
|
+
source: "ForgeQE monitor",
|
|
69
|
+
title: m.title,
|
|
70
|
+
status: m.items.some(failing) ? "bad" : "good",
|
|
71
|
+
subtitle: `${m.test ? "Test alert · " : ""}Round at ${clock(m.at)} · ${m.passed}/${m.total} checks passing`,
|
|
72
|
+
items: m.items.map((i) => ({
|
|
73
|
+
line: itemLine(i, m.at),
|
|
74
|
+
status: failing(i) ? "bad" : "good",
|
|
75
|
+
details: itemDetail(i),
|
|
76
|
+
...(i.result?.case.url ? { links: [{ title: "Test case", url: i.result.case.url }] } : {}),
|
|
77
|
+
})),
|
|
78
|
+
...(m.reportUrl ? { actions: [{ title: "Open report", url: m.reportUrl }] } : {}),
|
|
79
|
+
...(m.report && !m.reportUrl ? { note: `Report: ${m.report}` } : {}),
|
|
80
|
+
attachments: m.items
|
|
81
|
+
.filter(failing)
|
|
82
|
+
.map((i) => failureShot(i.result?.runDir, i.id))
|
|
83
|
+
.filter((a): a is { filename: string; path: string } => Boolean(a))
|
|
84
|
+
.slice(0, MAX_ATTACHMENTS),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const teamsPayload = (m: AlertMessage) => teamsCard(monitorNotice(m))
|
|
89
|
+
export const emailContent = (m: AlertMessage) => noticeEmail(monitorNotice(m))
|
|
90
|
+
|
|
91
|
+
/** Send the round to every configured channel; a channel that fails does not stop the others. */
|
|
92
|
+
export function sendAlerts(alerts: MonitorAlerts, m: AlertMessage, deps: SendDeps = {}): Promise<Sent[]> {
|
|
93
|
+
return sendNotice(alerts, monitorNotice(m), deps)
|
|
94
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Card } from "../card.ts"
|
|
2
|
+
import { lockMonitor, roundCard, runMonitor, type MonitorOptions, type RoundResult } from "./run.ts"
|
|
3
|
+
|
|
4
|
+
/** A monitor running inside this process (the shell, or `forgeqe mcp`) until it is stopped or the process ends. */
|
|
5
|
+
export type BackgroundMonitor = {
|
|
6
|
+
outDir: string
|
|
7
|
+
checks: string[]
|
|
8
|
+
everyMs: number
|
|
9
|
+
startedAt: Date
|
|
10
|
+
rounds: number
|
|
11
|
+
last?: RoundResult
|
|
12
|
+
nextAt?: Date
|
|
13
|
+
error?: string
|
|
14
|
+
stop(): void
|
|
15
|
+
done: Promise<number>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const running = new Map<string, BackgroundMonitor>()
|
|
19
|
+
|
|
20
|
+
export function backgroundMonitors(): BackgroundMonitor[] {
|
|
21
|
+
return [...running.values()]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Start rounds in the background; each round is reported as a card. Throws when a monitor already uses the folder. */
|
|
25
|
+
export function startBackgroundMonitor(opts: MonitorOptions & { onCard?: (card: Card) => void; cwd?: string }): BackgroundMonitor {
|
|
26
|
+
const outDir = opts.config.monitor.outDir
|
|
27
|
+
const other = running.get(outDir)
|
|
28
|
+
if (other) throw new Error(`A monitor is already running in ${outDir} (${other.checks.join(" ")}). Stop it first.`)
|
|
29
|
+
lockMonitor(outDir)()
|
|
30
|
+
const controller = new AbortController()
|
|
31
|
+
const bg: BackgroundMonitor = {
|
|
32
|
+
outDir,
|
|
33
|
+
checks: opts.checks,
|
|
34
|
+
everyMs: opts.config.monitor.everyMs,
|
|
35
|
+
startedAt: new Date(),
|
|
36
|
+
rounds: 0,
|
|
37
|
+
stop: () => controller.abort(),
|
|
38
|
+
done: Promise.resolve(0),
|
|
39
|
+
}
|
|
40
|
+
bg.done = runMonitor({
|
|
41
|
+
...opts,
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
onRound: (r, nextAt) => {
|
|
44
|
+
bg.rounds++
|
|
45
|
+
bg.last = r
|
|
46
|
+
bg.nextAt = nextAt
|
|
47
|
+
opts.onCard?.(roundCard(r, nextAt, opts.cwd))
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
.catch((err) => {
|
|
51
|
+
bg.error = (err as Error).message || String(err)
|
|
52
|
+
opts.onCard?.({ status: "fail", title: "Monitor stopped", body: [bg.error] })
|
|
53
|
+
return 1
|
|
54
|
+
})
|
|
55
|
+
.finally(() => {
|
|
56
|
+
if (running.get(outDir) === bg) running.delete(outDir)
|
|
57
|
+
})
|
|
58
|
+
running.set(outDir, bg)
|
|
59
|
+
return bg
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stop the monitor on `outDir`, or every one; resolves once their rounds have wound down. */
|
|
63
|
+
export async function stopBackgroundMonitors(outDir?: string): Promise<BackgroundMonitor[]> {
|
|
64
|
+
const picked = outDir ? backgroundMonitors().filter((m) => m.outDir === outDir) : backgroundMonitors()
|
|
65
|
+
for (const m of picked) m.stop()
|
|
66
|
+
await Promise.all(picked.map((m) => m.done))
|
|
67
|
+
return picked
|
|
68
|
+
}
|