@capagents/forgeqe 1.3.2 → 2.0.1

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.
Files changed (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,350 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { boolFlag, configOpts, strFlag, type Flags } from "../cli/args.ts"
6
+ import { terminalAsk } from "../cli/terminal.ts"
7
+ import { loadConfig, type AppConfig } from "../config.ts"
8
+ import { engineEnv, ensureEngine } from "../engine/setup.ts"
9
+ import type { DesktopEvent, DesktopResult } from "./bridge.ts"
10
+ import { junitXml, type JunitCase } from "./junit.ts"
11
+ import {
12
+ applyHeals,
13
+ checkApp,
14
+ checkDesktopConfig,
15
+ desktopOutDir,
16
+ exploreGoal,
17
+ hasModel,
18
+ parseCsv,
19
+ parseVariables,
20
+ privateDesktopSecrets,
21
+ recordByHand,
22
+ recordingNotes,
23
+ replayWorkflow,
24
+ visionProblem,
25
+ } from "./run.ts"
26
+ import { NOT_WINDOWS, desktopDoctor, ensureDesktop, ufoCommit, ufoDir } from "./setup.ts"
27
+
28
+ /** `forgeqe desktop`: automate Windows thick-client apps (docs/DESKTOP.md). */
29
+
30
+ export { applyHeals, parseVariables, privateDesktopSecrets, visionProblem }
31
+
32
+ export const DESKTOP_USAGE = `Usage:
33
+ forgeqe desktop setup [--ufo] [--force] set up the desktop engine (Windows); --ufo also fetches the pinned UFO²
34
+ forgeqe desktop apps [--env NAME] the desktop apps and secrets of the environment (env.yaml) and desktop:
35
+ forgeqe desktop validate <workflow.yaml> check a workflow without running it (any OS)
36
+ forgeqe desktop explore "<goal>" --app NAME [--name TEXT] [--out DIR] [--yes]
37
+ UFO² does the goal on the live app; the run, with checks of the
38
+ outcome, is saved as workflow.yaml
39
+ forgeqe desktop record --app NAME [--name TEXT] [--out DIR]
40
+ you do the steps in the app; your clicks and keys are saved as
41
+ workflow.yaml (no model). Ctrl+Shift+F11 adds a check of the control
42
+ under the mouse; Ctrl+Shift+F12 or closing the app stops
43
+ forgeqe desktop replay <workflow.yaml|folder> ... [name=value ...] [--data rows.csv] [--no-heal] [--accept-heals]
44
+ [--update-baselines] [--junit results.xml] [--out DIR]
45
+ replay workflows with no model (a folder: each workflow in it; once
46
+ per CSV row with --data); a step whose control moved is healed;
47
+ --junit writes the results for CI
48
+ forgeqe desktop doctor whether replay can run here unattended (engine, unlocked session)`
49
+
50
+ export function printer(color: boolean): (ev: DesktopEvent) => void {
51
+ const paint = (code: string, s: string) => (color ? `\u001b[${code}m${s}\u001b[0m` : s)
52
+ return (ev) => {
53
+ if (ev.type === "log" && ev.text && ev.level !== "debug") console.log(paint("2", ev.text))
54
+ if (ev.type === "step") {
55
+ const mark = ev.ok ? paint("32", "✓") : paint("31", "✗")
56
+ const healed = ev.healed_target ? paint("33", " (healed)") : ""
57
+ const tries = ev.attempts && ev.attempts > 1 ? paint("33", ` (${ev.attempts} tries)`) : ""
58
+ const closed = ev.dismissed?.length ? paint("33", ` (closed ${ev.dismissed.join(", ")})`) : ""
59
+ const note = ev.note ? paint("2", `: ${ev.note}`) : ""
60
+ console.log(`${mark} ${String(ev.index).padStart(3)} ${ev.op}${healed}${tries}${closed}${ev.error ? `: ${ev.error}` : note}`)
61
+ }
62
+ }
63
+ }
64
+
65
+ export function abortOnSigint(): { signal: AbortSignal; done: () => void } {
66
+ const controller = new AbortController()
67
+ const onSigint = () => controller.abort()
68
+ process.once("SIGINT", onSigint)
69
+ return { signal: controller.signal, done: () => process.off("SIGINT", onSigint) }
70
+ }
71
+
72
+ export const color = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
73
+
74
+ function checkedConfig(flags: Flags, needsModel: boolean): AppConfig {
75
+ const cfg = loadConfig(configOpts(flags))
76
+ checkDesktopConfig(cfg, needsModel)
77
+ return cfg
78
+ }
79
+
80
+ export async function offerHeals(workflow: string, outDir: string, heals: NonNullable<DesktopResult["heals"]>, flags: Flags, signal: AbortSignal): Promise<void> {
81
+ if (!heals.length) return
82
+ const ask = boolFlag(flags, "accept-heals") ? undefined : terminalAsk(signal)
83
+ const accept =
84
+ boolFlag(flags, "accept-heals") ||
85
+ (ask
86
+ ? (await ask({
87
+ title: `${heals.length} step${heals.length === 1 ? "" : "s"} healed. Write the new locators into ${path.basename(workflow)}?`,
88
+ lines: heals.map((h) => ` step ${h.index}: ${JSON.stringify(h.old)} → ${JSON.stringify(h.new)}`),
89
+ options: [
90
+ { id: "no", label: "No, keep the workflow as it is (heals.json has them)" },
91
+ { id: "yes", label: "Yes, update the workflow" },
92
+ ],
93
+ })) === "yes"
94
+ : false)
95
+ if (accept) {
96
+ applyHeals(workflow, heals)
97
+ console.log(`updated ${workflow}`)
98
+ } else console.log(`heals ${path.join(outDir, "heals.json")} (the workflow is unchanged; rerun with --accept-heals to write them)`)
99
+ }
100
+
101
+ /** The workflows the replay arguments name: .yaml files, and the desktop workflows directly inside a folder. */
102
+ export function workflowFiles(paths: string[], cwd: string = process.cwd(), kind: "desktop" | "mobile" = "desktop"): string[] {
103
+ const format = new RegExp(`^format:\\s*["']?forgeqe-${kind}-v1["']?\\s*$`, "m")
104
+ const out: string[] = []
105
+ for (const p of paths) {
106
+ const full = path.resolve(cwd, p)
107
+ if (!existsSync(full)) throw new Error(`No workflow at ${full}`)
108
+ if (statSync(full).isDirectory()) {
109
+ const found = readdirSync(full)
110
+ .filter((f) => /\.ya?ml$/i.test(f))
111
+ .sort()
112
+ .map((f) => path.join(full, f))
113
+ .filter((f) => statSync(f).isFile() && format.test(readFileSync(f, "utf8")))
114
+ if (!found.length) throw new Error(`${full} has no ${kind} workflows (.yaml files with format: forgeqe-${kind}-v1)`)
115
+ out.push(...found)
116
+ } else if (/\.ya?ml$/i.test(full)) out.push(full)
117
+ else throw new Error(`${full} is not a workflow (.yaml or .yml) or a folder of them`)
118
+ }
119
+ return [...new Set(out)]
120
+ }
121
+
122
+ /** Replay words as workflow paths and name=value variables. */
123
+ export function splitReplayArgs(words: string[]): { paths: string[]; variables: Record<string, string> } {
124
+ const vars = words.filter((w) => /^[A-Za-z_][A-Za-z0-9_]{0,63}=/.test(w))
125
+ return { paths: words.filter((w) => !vars.includes(w)), variables: parseVariables(vars) }
126
+ }
127
+
128
+ function caseDir(outDir: string, used: Set<string>, file: string): string {
129
+ const base = path.basename(file).replace(/\.ya?ml$/i, "").replace(/[^A-Za-z0-9_.-]/g, "_") || "workflow"
130
+ let name = base
131
+ for (let i = 2; used.has(name); i++) name = `${base}-${i}`
132
+ used.add(name)
133
+ return path.join(outDir, name)
134
+ }
135
+
136
+ async function cmdReplay(args: string[], flags: Flags): Promise<number> {
137
+ const { paths, variables } = splitReplayArgs(args)
138
+ if (!paths.length) throw new Error(DESKTOP_USAGE)
139
+ const workflows = workflowFiles(paths)
140
+ const dataFile = strFlag(flags, "data")
141
+ const rows = dataFile ? parseCsv(readFileSync(path.resolve(dataFile), "utf8")) : [{}]
142
+ if (!rows.length) throw new Error(`${dataFile} has a header but no rows`)
143
+ const junit = strFlag(flags, "junit")
144
+ const cfg = checkedConfig(flags, false)
145
+ const healWanted = cfg.desktop.heal && !boolFlag(flags, "no-heal")
146
+ const updateBaselines = boolFlag(flags, "update-baselines")
147
+ if (healWanted && !hasModel(cfg)) console.error("Note: no model in llms.json, so moved controls will not be healed.")
148
+ await ensureDesktop((t) => console.error(t))
149
+ const outDir = desktopOutDir(cfg, "replay", strFlag(flags, "out"))
150
+ const { signal, done } = abortOnSigint()
151
+ const many = workflows.length > 1
152
+ try {
153
+ const results: { workflow: string; row: number; ok: boolean; report: string; error?: string }[] = []
154
+ const cases: JunitCase[] = []
155
+ const used = new Set<string>()
156
+ for (const workflow of workflows) {
157
+ const wfDir = many ? desktopOutDir(cfg, "replay", caseDir(outDir, used, workflow)) : outDir
158
+ const label = path.relative(process.cwd(), workflow) || workflow
159
+ if (many) console.log(`\n${label}`)
160
+ let offered = false
161
+ for (let i = 0; i < rows.length; i++) {
162
+ if (signal.aborted) break
163
+ const rowDir = rows.length > 1 ? desktopOutDir(cfg, "replay", path.join(wfDir, `row-${String(i + 1).padStart(3, "0")}`)) : wfDir
164
+ if (rows.length > 1) console.log(`\nrow ${i + 1} of ${rows.length}`)
165
+ const started = Date.now()
166
+ const run = await replayWorkflow({ cfg, workflow, variables: { ...variables, ...rows[i] }, heal: healWanted, updateBaselines, outDir: rowDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
167
+ const failed = (run.result.steps || []).find((s) => !s.ok)
168
+ const error = run.result.error || (failed ? `step ${failed.index} (${failed.op}): ${failed.error || "failed"}` : run.result.ok ? "" : "failed")
169
+ if (run.result.error) console.error(run.result.error)
170
+ console.log(`report ${run.report}`)
171
+ results.push({ workflow: label, row: i + 1, ok: run.result.ok, report: run.report, ...(error ? { error } : {}) })
172
+ cases.push({ suite: label, name: `${run.result.name || path.basename(workflow)}${rows.length > 1 ? ` (row ${i + 1})` : ""}`, ok: run.result.ok, seconds: (Date.now() - started) / 1000, ...(error ? { error } : {}), report: run.report })
173
+ // Heals are offered once per workflow, from the first row that has them: every row runs the same steps.
174
+ if (run.result.heals?.length && !offered) {
175
+ offered = true
176
+ await offerHeals(workflow, rowDir, run.result.heals, flags, signal)
177
+ }
178
+ }
179
+ }
180
+ const planned = workflows.length * rows.length
181
+ if (planned > 1) {
182
+ writeFileSync(path.join(outDir, "runs.json"), JSON.stringify(results, null, 2))
183
+ const passed = results.filter((r) => r.ok).length
184
+ console.log(`\n${passed} of ${planned} runs passed${results.length < planned ? ` (${planned - results.length} not run)` : ""} ${path.join(outDir, "runs.json")}`)
185
+ }
186
+ if (junit) {
187
+ const file = path.resolve(junit)
188
+ mkdirSync(path.dirname(file), { recursive: true })
189
+ writeFileSync(file, junitXml(cases))
190
+ console.log(`junit ${file}`)
191
+ }
192
+ return results.length === planned && results.every((r) => r.ok) ? 0 : 1
193
+ } finally {
194
+ done()
195
+ }
196
+ }
197
+
198
+ /** `forgeqe desktop doctor`: whether replay can run here, unattended: the engine set up, and a session that takes input. */
199
+ async function cmdDoctor(): Promise<number> {
200
+ const doc = desktopDoctor()
201
+ console.log(`engine ${doc.ready ? `ready (${doc.python})` : `not ready (${doc.error}); run forgeqe desktop setup`}`)
202
+ console.log(`explore ${doc.ufo ? `ready (UFO ${ufoCommit().slice(0, 12)})` : "not set up (forgeqe desktop setup --ufo)"}`)
203
+ if (!doc.ready) return 1
204
+ const r = spawnSync(doc.python, ["-m", "forgeqe_desktop", "doctor"], { env: engineEnv(), encoding: "utf8" })
205
+ let state: { session?: number; width?: number; height?: number; problem?: string | null } = {}
206
+ try {
207
+ state = JSON.parse((r.stdout || "").trim().split(/\r?\n/).pop() || "{}")
208
+ } catch {
209
+ state = { problem: (r.stderr || "the check did not run").trim().split(/\r?\n/).pop() }
210
+ }
211
+ console.log(`session ${state.session ?? "?"}${state.width ? `, screen ${state.width}x${state.height}` : ""}`)
212
+ console.log(`desktop ${state.problem ? `cannot be driven: ${state.problem}` : "logged in and unlocked"}`)
213
+ return state.problem ? 1 : 0
214
+ }
215
+
216
+ async function cmdExplore(args: string[], flags: Flags): Promise<number> {
217
+ const goal = args.join(" ").trim()
218
+ const app = strFlag(flags, "app")
219
+ if (!goal || !app) throw new Error(DESKTOP_USAGE)
220
+ const cfg = checkedConfig(flags, true)
221
+ checkApp(cfg, app)
222
+ const problem = visionProblem(cfg.activeProfile, cfg.llm)
223
+ if (problem) throw new Error(problem)
224
+ const { signal, done } = abortOnSigint()
225
+ try {
226
+ if (!boolFlag(flags, "yes")) {
227
+ const ask = terminalAsk(signal)
228
+ if (!ask) throw new Error("explore controls this desktop's mouse and keyboard; pass --yes to run it without a terminal to confirm in.")
229
+ const answer = await ask({
230
+ title: `UFO² will control this desktop's mouse and keyboard to: ${goal}`,
231
+ detail: `It starts ${app}, acts in its windows only (no shell commands), and its model is ${cfg.activeProfile}. Use a test machine and a test account.`,
232
+ options: [
233
+ { id: "no", label: "Cancel" },
234
+ { id: "yes", label: "Start" },
235
+ ],
236
+ })
237
+ if (answer !== "yes") return 1
238
+ }
239
+ const outDir = desktopOutDir(cfg, "explore", strFlag(flags, "out"))
240
+ const { result, report } = await exploreGoal({ cfg, goal, app, name: strFlag(flags, "name"), outDir, signal, onEvent: printer(color()), log: (t) => console.error(t) })
241
+ if (result.error) console.error(result.error)
242
+ console.log(`\nreport ${report}`)
243
+ if (result.workflow) {
244
+ console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
245
+ console.log(`replay forgeqe desktop replay "${result.workflow}"`)
246
+ }
247
+ return result.ok ? 0 : 1
248
+ } finally {
249
+ done()
250
+ }
251
+ }
252
+
253
+ async function cmdRecord(flags: Flags): Promise<number> {
254
+ const app = strFlag(flags, "app")
255
+ if (!app) throw new Error(DESKTOP_USAGE)
256
+ const cfg = checkedConfig(flags, false)
257
+ checkApp(cfg, app)
258
+ const outDir = desktopOutDir(cfg, "record", strFlag(flags, "out"))
259
+ const { signal, done } = abortOnSigint()
260
+ try {
261
+ console.log(`Starting ${app}. Do the steps in it; Ctrl+Shift+F11 over a control adds a check of what it shows; Ctrl+Shift+F12 or closing the app stops.`)
262
+ const onEvent = (ev: DesktopEvent) => {
263
+ if (ev.type === "recorded") console.log(`${paintIf("36", "●")} ${String(ev.index).padStart(3)} ${ev.op}${ev.keys ? ` ${ev.keys}` : ""}${ev.target ? ` ${String(ev.target.name || ev.target.automation_id || "")}` : ""}`)
264
+ else printer(color())(ev)
265
+ }
266
+ const { result, report } = await recordByHand({ cfg, app, name: strFlag(flags, "name"), outDir, signal, onEvent, log: (t) => console.error(t) })
267
+ if (result.error) console.error(result.error)
268
+ console.log(`\nreport ${report}`)
269
+ if (result.workflow) {
270
+ console.log(`workflow ${result.workflow} (${result.recorded} steps, ${result.checks ?? 0} checks)`)
271
+ for (const s of result.skipped || []) console.log(` not recorded: ${s.action}: ${s.reason}`)
272
+ for (const n of recordingNotes(cfg, result)) console.log(`note ${n}`)
273
+ console.log(`replay forgeqe desktop replay "${result.workflow}"`)
274
+ }
275
+ return result.ok ? 0 : 1
276
+ } finally {
277
+ done()
278
+ }
279
+ }
280
+
281
+ function paintIf(code: string, s: string): string {
282
+ return color() ? `\u001b[${code}m${s}\u001b[0m` : s
283
+ }
284
+
285
+ async function cmdValidate(args: string[]): Promise<number> {
286
+ const file = args[0]
287
+ if (!file) throw new Error(DESKTOP_USAGE)
288
+ // Any Python with PyYAML runs validate: the desktop engine's on Windows, else the browser engine's.
289
+ const desktop = desktopDoctor()
290
+ const python = desktop.ready ? desktop.python : await ensureEngine((t) => console.error(t))
291
+ const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", path.resolve(file)], { env: engineEnv(), encoding: "utf8" })
292
+ if (r.error) throw r.error
293
+ process.stdout.write(r.stdout || "")
294
+ process.stderr.write(r.stderr || "")
295
+ return r.status ?? 1
296
+ }
297
+
298
+ function cmdApps(flags: Flags): number {
299
+ const cfg = loadConfig(configOpts(flags))
300
+ const apps = Object.entries(cfg.desktop.apps)
301
+ if (!apps.length) {
302
+ console.log("No apps in forgeqe.yaml desktop.apps. Add one:\n\ndesktop:\n apps:\n billing:\n path: C:\\Program Files\\Contoso\\Billing\\Billing.exe\n args: []\n secrets: [BILLING_PASSWORD]")
303
+ return 0
304
+ }
305
+ for (const [name, raw] of apps) {
306
+ const r = (raw && typeof raw === "object" ? raw : {}) as { path?: unknown; args?: unknown }
307
+ console.log(`${name.padEnd(16)} ${String(r.path ?? "(no path)")}${Array.isArray(r.args) && r.args.length ? ` ${JSON.stringify(r.args)}` : ""}`)
308
+ }
309
+ console.log(`\nsecrets ${cfg.desktop.secrets.join(", ") || "none"}`)
310
+ const own = privateDesktopSecrets(cfg)
311
+ if (own.length) console.log(` ! ${own.join(", ")} ${own.length === 1 ? "is" : "are"} ForgeQE's own; remove from desktop.secrets`)
312
+ return 0
313
+ }
314
+
315
+ async function cmdSetup(flags: Flags): Promise<number> {
316
+ const python = await ensureDesktop((t) => console.log(t), { ufo: boolFlag(flags, "ufo"), force: boolFlag(flags, "force") })
317
+ const doc = desktopDoctor(python)
318
+ console.log(`python ${python}`)
319
+ console.log(`replay ${doc.ready ? "ready" : `not ready (${doc.error})`}`)
320
+ console.log(`explore ${doc.ufo ? `ready (UFO ${ufoCommit().slice(0, 12)} in ${ufoDir()})` : "not set up (run forgeqe desktop setup --ufo)"}`)
321
+ return doc.ready ? 0 : 1
322
+ }
323
+
324
+ export async function cmdDesktop(args: string[], flags: Flags): Promise<number> {
325
+ const [sub, ...rest] = args
326
+ const windowsOnly = sub === "setup" || sub === "replay" || sub === "explore" || sub === "record" || sub === "doctor"
327
+ if (windowsOnly && process.platform !== "win32") {
328
+ console.error(NOT_WINDOWS)
329
+ return 2
330
+ }
331
+ switch (sub) {
332
+ case "setup":
333
+ return cmdSetup(flags)
334
+ case "apps":
335
+ return cmdApps(flags)
336
+ case "validate":
337
+ return cmdValidate(rest)
338
+ case "doctor":
339
+ return cmdDoctor()
340
+ case "replay":
341
+ return cmdReplay(rest, flags)
342
+ case "explore":
343
+ return cmdExplore(rest, flags)
344
+ case "record":
345
+ return cmdRecord(flags)
346
+ default:
347
+ console.log(DESKTOP_USAGE)
348
+ return sub ? 2 : 0
349
+ }
350
+ }
@@ -0,0 +1,217 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+
6
+ import YAML, { isMap, isSeq, type Document, type YAMLMap, type YAMLSeq } from "yaml"
7
+
8
+ import { engineEnv, ensureEngine } from "../engine/setup.ts"
9
+ import { lineDiff } from "../prompts.ts"
10
+ import { desktopDoctor } from "./setup.ts"
11
+
12
+ /**
13
+ * Changes to a desktop workflow that ForgeQE's agent proposes from chat (a longer timeout, a wait_for step, a dialog to
14
+ * dismiss, a healed locator), applied to the YAML with its comments and layout kept. Each change has a fixed shape and
15
+ * the result is checked by the engine's own validator before anything is written; the caller shows the diff and asks.
16
+ *
17
+ * Step numbers are the workflow's as it is now (the report's numbers), whatever earlier changes in the same call do.
18
+ */
19
+
20
+ export const STEP_OPS = [
21
+ "launch", "click", "double_click", "type", "select", "keys", "expect", "expect_image", "wait", "wait_for", "close",
22
+ // mobile workflows (forgeqe-mobile-v1); the engine's validator checks each op belongs to the workflow's format
23
+ "tap", "double_tap", "long_press", "swipe", "scroll_to", "back", "hide_keyboard",
24
+ ] as const
25
+ const TIMING_FIELDS = ["timeout", "retries"] as const
26
+ const STEP_FIELDS = ["target", "text", "value", "keys", "seconds", "tolerance", "gone", "image", "ignore", "note", "timeout", "retries", "direction"] as const
27
+ const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
28
+ const MAX_CHANGES = 20
29
+
30
+ export type WorkflowChange =
31
+ | { op: "set"; field: (typeof TIMING_FIELDS)[number]; value: number; step?: number }
32
+ | { op: "unset"; field: (typeof TIMING_FIELDS)[number]; step?: number }
33
+ | { op: "set_step"; step: number; field: (typeof STEP_FIELDS)[number]; value: unknown }
34
+ | { op: "add_dismiss"; target: Record<string, unknown> }
35
+ | { op: "insert_step"; before?: number; after?: number; step: Record<string, unknown> }
36
+ | { op: "remove_step"; step: number }
37
+ | { op: "set_variable"; name: string; value: string }
38
+
39
+ export type WorkflowEdit = { before: string; after: string; diff: string[]; summary: string[] }
40
+
41
+ /** The engine's validator: null when the text is a valid workflow, else why not. */
42
+ export type ValidateWorkflow = (text: string) => Promise<string | null>
43
+
44
+ const isObject = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === "object" && !Array.isArray(v)
45
+
46
+ function stepNumber(v: unknown, count: number, what: string): number {
47
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > count) throw new Error(`${what} must be a step number from 1 to ${count}`)
48
+ return v
49
+ }
50
+
51
+ /** The changes as given by the caller (the model): each checked against its shape; anything else is refused. */
52
+ export function checkChanges(raw: unknown, count: number): WorkflowChange[] {
53
+ if (!Array.isArray(raw) || !raw.length) throw new Error("changes must be a non-empty list")
54
+ if (raw.length > MAX_CHANGES) throw new Error(`at most ${MAX_CHANGES} changes in one edit`)
55
+ return raw.map((c, i) => {
56
+ const where = `change ${i + 1}`
57
+ if (!isObject(c)) throw new Error(`${where} must be an object`)
58
+ const allow = (keys: string[]) => {
59
+ const extra = Object.keys(c).filter((k) => !keys.includes(k))
60
+ if (extra.length) throw new Error(`${where} (${String(c.op)}): unknown keys ${extra.join(", ")}`)
61
+ }
62
+ switch (c.op) {
63
+ case "set":
64
+ case "unset": {
65
+ allow(c.op === "set" ? ["op", "field", "value", "step"] : ["op", "field", "step"])
66
+ if (!TIMING_FIELDS.includes(c.field as never)) throw new Error(`${where}: field must be ${TIMING_FIELDS.join(" or ")}`)
67
+ if (c.op === "set" && (typeof c.value !== "number" || !Number.isFinite(c.value))) throw new Error(`${where}: value must be a number`)
68
+ const step = c.step === undefined ? undefined : stepNumber(c.step, count, `${where}: step`)
69
+ return { op: c.op, field: c.field, ...(c.op === "set" ? { value: c.value } : {}), ...(step ? { step } : {}) } as WorkflowChange
70
+ }
71
+ case "set_step":
72
+ allow(["op", "step", "field", "value"])
73
+ if (!STEP_FIELDS.includes(c.field as never)) throw new Error(`${where}: field must be one of ${STEP_FIELDS.join(", ")}`)
74
+ if (c.value === undefined) throw new Error(`${where}: value is required`)
75
+ return { op: "set_step", step: stepNumber(c.step, count, `${where}: step`), field: c.field, value: c.value } as WorkflowChange
76
+ case "add_dismiss":
77
+ allow(["op", "target"])
78
+ if (!isObject(c.target)) throw new Error(`${where}: target must be the dialog button's UIA properties`)
79
+ return { op: "add_dismiss", target: c.target }
80
+ case "insert_step": {
81
+ allow(["op", "before", "after", "step"])
82
+ if ((c.before === undefined) === (c.after === undefined)) throw new Error(`${where}: give before or after, a step number`)
83
+ if (!isObject(c.step) || !STEP_OPS.includes(c.step.op as never)) throw new Error(`${where}: step must be a workflow step with op one of ${STEP_OPS.join(", ")}`)
84
+ const at = c.before !== undefined ? { before: stepNumber(c.before, count, `${where}: before`) } : { after: stepNumber(c.after, count, `${where}: after`) }
85
+ return { op: "insert_step", ...at, step: c.step }
86
+ }
87
+ case "remove_step":
88
+ allow(["op", "step"])
89
+ return { op: "remove_step", step: stepNumber(c.step, count, `${where}: step`) }
90
+ case "set_variable":
91
+ allow(["op", "name", "value"])
92
+ if (typeof c.name !== "string" || !VAR_NAME.test(c.name)) throw new Error(`${where}: name must be letters, digits and _`)
93
+ if (typeof c.value !== "string" && typeof c.value !== "number") throw new Error(`${where}: value must be text`)
94
+ return { op: "set_variable", name: c.name, value: String(c.value) }
95
+ default:
96
+ throw new Error(`${where}: op must be set, unset, set_step, add_dismiss, insert_step, remove_step or set_variable`)
97
+ }
98
+ })
99
+ }
100
+
101
+ function describe(c: WorkflowChange): string {
102
+ switch (c.op) {
103
+ case "set":
104
+ return `${c.step ? `step ${c.step}` : "the workflow"}: ${c.field} ${c.value}`
105
+ case "unset":
106
+ return `${c.step ? `step ${c.step}` : "the workflow"}: no ${c.field}`
107
+ case "set_step":
108
+ return `step ${c.step}: ${c.field} = ${JSON.stringify(c.value)}`
109
+ case "add_dismiss":
110
+ return `dismiss ${JSON.stringify(c.target)} whenever it is in the way`
111
+ case "insert_step":
112
+ return `new ${String(c.step.op)} step ${c.before ? `before step ${c.before}` : `after step ${c.after}`}`
113
+ case "remove_step":
114
+ return `remove step ${c.step}`
115
+ case "set_variable":
116
+ return `variable ${c.name} = ${JSON.stringify(c.value)}`
117
+ }
118
+ }
119
+
120
+ /** Apply the changes to the workflow's text; comments and layout are kept. Throws on a change that cannot apply. */
121
+ export function applyChanges(text: string, changes: WorkflowChange[]): string {
122
+ const doc: Document = YAML.parseDocument(text)
123
+ if (doc.errors.length) throw new Error(`the workflow is not valid YAML: ${doc.errors[0]!.message}`)
124
+ const steps = doc.get("steps", true)
125
+ if (!isSeq(steps)) throw new Error("the workflow has no steps list")
126
+ const seq = steps as YAMLSeq
127
+ const original = [...seq.items]
128
+ const at = (n: number): YAMLMap => {
129
+ const pos = seq.items.indexOf(original[n - 1]!)
130
+ if (pos < 0) throw new Error(`step ${n} was removed by an earlier change`)
131
+ const node = seq.items[pos]
132
+ if (!isMap(node)) throw new Error(`step ${n} is not a mapping`)
133
+ return node as YAMLMap
134
+ }
135
+ for (const c of changes) {
136
+ switch (c.op) {
137
+ case "set":
138
+ if (c.step) at(c.step).set(c.field, c.value)
139
+ else doc.set(c.field, c.value)
140
+ break
141
+ case "unset":
142
+ if (c.step) at(c.step).delete(c.field)
143
+ else doc.delete(c.field)
144
+ break
145
+ case "set_step":
146
+ at(c.step).set(c.field, doc.createNode(c.value, { flow: isObject(c.value) || Array.isArray(c.value) }))
147
+ break
148
+ case "add_dismiss": {
149
+ let list = doc.get("dismiss", true)
150
+ if (!isSeq(list)) {
151
+ doc.set("dismiss", doc.createNode([]))
152
+ list = doc.get("dismiss", true)
153
+ }
154
+ ;(list as YAMLSeq).add(doc.createNode(c.target, { flow: true }))
155
+ break
156
+ }
157
+ case "insert_step": {
158
+ const anchor = at((c.before ?? c.after)!)
159
+ const pos = seq.items.indexOf(anchor) + (c.after ? 1 : 0)
160
+ const node = doc.createNode(c.step) as YAMLMap
161
+ for (const pair of node.items) if (isMap(pair.value) || isSeq(pair.value)) pair.value.flow = true
162
+ seq.items.splice(pos, 0, node)
163
+ break
164
+ }
165
+ case "remove_step": {
166
+ const node = at(c.step)
167
+ seq.items.splice(seq.items.indexOf(node), 1)
168
+ break
169
+ }
170
+ case "set_variable": {
171
+ if (!isMap(doc.get("variables", true))) doc.set("variables", doc.createNode({}))
172
+ doc.setIn(["variables", c.name], c.value)
173
+ break
174
+ }
175
+ }
176
+ }
177
+ return doc.toString()
178
+ }
179
+
180
+ /** The proposed edit of `file`: before, after, diff and a line per change; checked by `validate`, nothing written. */
181
+ export async function proposeEdit(file: string, rawChanges: unknown, validate: ValidateWorkflow): Promise<WorkflowEdit> {
182
+ const before = readFileSync(file, "utf8")
183
+ const parsed = YAML.parse(before) as { steps?: unknown[] } | null
184
+ const count = Array.isArray(parsed?.steps) ? parsed!.steps.length : 0
185
+ const changes = checkChanges(rawChanges, count)
186
+ const after = applyChanges(before, changes)
187
+ const problem = await validate(after)
188
+ if (problem) throw new Error(`The changed workflow would not run: ${problem}`)
189
+ return { before, after, diff: lineDiff(before.replace(/\n$/, ""), after.replace(/\n$/, "")), summary: changes.map(describe) }
190
+ }
191
+
192
+ /** Write an accepted edit, keeping the old text in `backupDir`; refuses when the file changed since it was proposed. */
193
+ export function writeEdit(file: string, edit: WorkflowEdit, backupDir: string): string {
194
+ if (readFileSync(file, "utf8") !== edit.before) throw new Error(`${path.basename(file)} changed since the edit was proposed; propose it again.`)
195
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
196
+ const backup = path.join(backupDir, `${stamp}-${path.basename(file)}`)
197
+ writeFileSync(backup, edit.before)
198
+ writeFileSync(file, edit.after)
199
+ return backup
200
+ }
201
+
202
+ /** The engine's own validator (`python -m forgeqe_desktop validate`), run on the text in a temporary file. */
203
+ export const engineValidate: ValidateWorkflow = async (text) => {
204
+ const desktop = desktopDoctor()
205
+ const python = desktop.ready ? desktop.python : await ensureEngine(() => {})
206
+ const dir = mkdtempSync(path.join(tmpdir(), "fq-wf-"))
207
+ try {
208
+ const file = path.join(dir, "workflow.yaml")
209
+ writeFileSync(file, text)
210
+ const r = spawnSync(python, ["-m", "forgeqe_desktop", "validate", file], { env: engineEnv(), encoding: "utf8" })
211
+ if (r.error) throw r.error
212
+ if (r.status === 0) return null
213
+ return ((r.stderr || r.stdout || "").trim().split(/\r?\n/).pop() || "it is not a valid workflow").replace(/^error:\s*/, "")
214
+ } finally {
215
+ rmSync(dir, { recursive: true, force: true })
216
+ }
217
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * JUnit XML for a desktop suite (`forgeqe desktop replay ... --junit FILE`), the format CI servers show as test
3
+ * results. Every value is escaped: step errors and control names come from the app under test.
4
+ */
5
+
6
+ export type JunitCase = { suite: string; name: string; ok: boolean; seconds: number; error?: string; report?: string }
7
+
8
+ /** `value` as XML text or attribute: markup escaped, and characters XML 1.0 cannot hold dropped. */
9
+ export function xmlEscape(value: unknown): string {
10
+ return String(value ?? "")
11
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "")
12
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;")
17
+ .replace(/'/g, "&apos;")
18
+ }
19
+
20
+ export function junitXml(cases: JunitCase[], name = "forgeqe desktop"): string {
21
+ const failures = cases.filter((c) => !c.ok).length
22
+ const seconds = cases.reduce((n, c) => n + c.seconds, 0)
23
+ const body = cases
24
+ .map((c) => {
25
+ const out = c.report ? `\n <system-out>${xmlEscape(`report ${c.report}`)}</system-out>` : ""
26
+ const fail = c.ok ? "" : `\n <failure message="${xmlEscape((c.error || "failed").split(/\r?\n/)[0]!.slice(0, 500))}">${xmlEscape(c.error || "failed")}</failure>`
27
+ return ` <testcase classname="${xmlEscape(c.suite)}" name="${xmlEscape(c.name)}" time="${c.seconds.toFixed(3)}">${fail}${out}\n </testcase>`
28
+ })
29
+ .join("\n")
30
+ return `<?xml version="1.0" encoding="UTF-8"?>
31
+ <testsuite name="${xmlEscape(name)}" tests="${cases.length}" failures="${failures}" errors="0" time="${seconds.toFixed(3)}">
32
+ ${body}
33
+ </testsuite>
34
+ `
35
+ }