@asrunjs/asrun 0.0.0-stage → 0.5.2

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 (305) hide show
  1. package/.claude/commands/explore-case.md +18 -0
  2. package/.claude/commands/gen-spec.md +16 -0
  3. package/.claude/commands/heal-spec.md +26 -0
  4. package/.claude/commands/make-case.md +11 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +146 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +116 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +146 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -0
  12. package/.claude/skills/generating-specs/SKILL.md +219 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +72 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +39 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +37 -0
  19. package/AGENTS.md +159 -0
  20. package/CLAUDE.md +2 -0
  21. package/LICENSE +21 -0
  22. package/README.md +511 -2
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/build-artifacts.d.ts +8 -0
  31. package/dist/lib/build-artifacts.js +10 -0
  32. package/dist/lib/case-doc.d.ts +311 -0
  33. package/dist/lib/case-doc.js +1117 -0
  34. package/dist/lib/case-id.d.ts +11 -0
  35. package/dist/lib/case-id.js +17 -0
  36. package/dist/lib/case-ops.d.ts +26 -0
  37. package/dist/lib/case-ops.js +110 -0
  38. package/dist/lib/cases.d.ts +16 -0
  39. package/dist/lib/cases.js +51 -0
  40. package/dist/lib/cli.d.ts +10 -0
  41. package/dist/lib/cli.js +18 -0
  42. package/dist/lib/command.d.ts +24 -0
  43. package/dist/lib/command.js +90 -0
  44. package/dist/lib/downloads.d.ts +42 -0
  45. package/dist/lib/downloads.js +84 -0
  46. package/dist/lib/email.d.ts +10 -0
  47. package/dist/lib/email.js +19 -0
  48. package/dist/lib/ffmpeg.d.ts +8 -0
  49. package/dist/lib/ffmpeg.js +50 -0
  50. package/dist/lib/fixtures.d.ts +40 -0
  51. package/dist/lib/fixtures.js +149 -0
  52. package/dist/lib/login-card.d.ts +22 -0
  53. package/dist/lib/login-card.js +183 -0
  54. package/dist/lib/node-cli.d.ts +15 -0
  55. package/dist/lib/node-cli.js +36 -0
  56. package/dist/lib/paths.d.ts +25 -0
  57. package/dist/lib/paths.js +88 -0
  58. package/dist/lib/pdf.d.ts +71 -0
  59. package/dist/lib/pdf.js +140 -0
  60. package/dist/lib/profiles.d.ts +62 -0
  61. package/dist/lib/profiles.js +174 -0
  62. package/dist/lib/ready.d.ts +36 -0
  63. package/dist/lib/ready.js +53 -0
  64. package/dist/lib/records.d.ts +33 -0
  65. package/dist/lib/records.js +61 -0
  66. package/dist/lib/run-filters.d.ts +19 -0
  67. package/dist/lib/run-filters.js +38 -0
  68. package/dist/lib/run-summary.d.ts +90 -0
  69. package/dist/lib/run-summary.js +35 -0
  70. package/dist/lib/session.d.ts +30 -0
  71. package/dist/lib/session.js +111 -0
  72. package/dist/lib/summary-reporter.d.ts +16 -0
  73. package/dist/lib/summary-reporter.js +146 -0
  74. package/dist/lib/targets.d.ts +19 -0
  75. package/dist/lib/targets.js +26 -0
  76. package/dist/lib/ui-build.d.ts +10 -0
  77. package/dist/lib/ui-build.js +30 -0
  78. package/dist/lib/values.d.ts +29 -0
  79. package/dist/lib/values.js +85 -0
  80. package/dist/lib/write-gate.d.ts +41 -0
  81. package/dist/lib/write-gate.js +129 -0
  82. package/dist/playwright.config.d.ts +2 -0
  83. package/dist/playwright.config.js +92 -0
  84. package/dist/scripts/build.d.ts +1 -0
  85. package/dist/scripts/build.js +21 -0
  86. package/dist/scripts/check-cases.d.ts +1 -0
  87. package/dist/scripts/check-cases.js +149 -0
  88. package/dist/scripts/cleanup.d.ts +1 -0
  89. package/dist/scripts/cleanup.js +25 -0
  90. package/dist/scripts/cli.d.ts +1 -0
  91. package/dist/scripts/cli.js +28 -0
  92. package/dist/scripts/doctor.d.ts +1 -0
  93. package/dist/scripts/doctor.js +296 -0
  94. package/dist/scripts/explore.d.ts +1 -0
  95. package/dist/scripts/explore.js +75 -0
  96. package/dist/scripts/go.mjs +188 -0
  97. package/dist/scripts/heal-evidence.mjs +143 -0
  98. package/dist/scripts/init.d.ts +22 -0
  99. package/dist/scripts/init.js +332 -0
  100. package/dist/scripts/lint-specs.d.ts +1 -0
  101. package/dist/scripts/lint-specs.js +141 -0
  102. package/dist/scripts/login.d.ts +1 -0
  103. package/dist/scripts/login.js +75 -0
  104. package/dist/scripts/new-case.d.ts +1 -0
  105. package/dist/scripts/new-case.js +19 -0
  106. package/dist/scripts/render-pdf.d.ts +1 -0
  107. package/dist/scripts/render-pdf.js +44 -0
  108. package/dist/scripts/report.d.ts +1 -0
  109. package/dist/scripts/report.js +14 -0
  110. package/dist/scripts/review-baselines.d.ts +1 -0
  111. package/dist/scripts/review-baselines.js +30 -0
  112. package/dist/scripts/run.d.ts +1 -0
  113. package/dist/scripts/run.js +423 -0
  114. package/dist/scripts/status.d.ts +1 -0
  115. package/dist/scripts/status.js +31 -0
  116. package/dist/scripts/ui-build.d.ts +1 -0
  117. package/dist/scripts/ui-build.js +7 -0
  118. package/dist/scripts/ui-dev.d.ts +1 -0
  119. package/dist/scripts/ui-dev.js +3 -0
  120. package/dist/scripts/ui.d.ts +1 -0
  121. package/dist/scripts/ui.js +13 -0
  122. package/dist/ui/dev.d.ts +1 -0
  123. package/dist/ui/dev.js +36 -0
  124. package/dist/ui/server/actions.d.ts +95 -0
  125. package/dist/ui/server/actions.js +365 -0
  126. package/dist/ui/server/ai-models.d.ts +3 -0
  127. package/dist/ui/server/ai-models.js +347 -0
  128. package/dist/ui/server/ai-stream.d.ts +66 -0
  129. package/dist/ui/server/ai-stream.js +408 -0
  130. package/dist/ui/server/app.d.ts +4 -0
  131. package/dist/ui/server/app.js +395 -0
  132. package/dist/ui/server/cases.d.ts +23 -0
  133. package/dist/ui/server/cases.js +170 -0
  134. package/dist/ui/server/files.d.ts +5 -0
  135. package/dist/ui/server/files.js +67 -0
  136. package/dist/ui/server/index.d.ts +1 -0
  137. package/dist/ui/server/index.js +33 -0
  138. package/dist/ui/server/jobs.d.ts +80 -0
  139. package/dist/ui/server/jobs.js +317 -0
  140. package/dist/ui/server/profiles.d.ts +35 -0
  141. package/dist/ui/server/profiles.js +371 -0
  142. package/dist/ui/server/watch-files.d.ts +4 -0
  143. package/dist/ui/server/watch-files.js +36 -0
  144. package/dist/ui/shared/ai-commands.d.ts +88 -0
  145. package/dist/ui/shared/ai-commands.js +177 -0
  146. package/dist/ui/shared/case-id.d.ts +1 -0
  147. package/dist/ui/shared/case-id.js +1 -0
  148. package/dist/ui/shared/jobs.d.ts +6 -0
  149. package/dist/ui/shared/jobs.js +7 -0
  150. package/dist/ui/shared/product.d.ts +3 -0
  151. package/dist/ui/shared/product.js +3 -0
  152. package/dist/ui/shared/run-filters.d.ts +1 -0
  153. package/dist/ui/shared/run-filters.js +1 -0
  154. package/dist/ui/shared/site-address.d.ts +6 -0
  155. package/dist/ui/shared/site-address.js +11 -0
  156. package/dist/ui/shared/types.d.ts +371 -0
  157. package/dist/ui/shared/types.js +1 -0
  158. package/docs/agents/framework-dev.md +79 -0
  159. package/docs/theme-guide.html +922 -0
  160. package/package.json +109 -3
  161. package/templates/.claude/commands/explore-case.md +22 -0
  162. package/templates/.claude/commands/gen-spec.md +21 -0
  163. package/templates/.claude/commands/heal-spec.md +31 -0
  164. package/templates/.claude/commands/make-case.md +13 -0
  165. package/templates/.claude/settings.json +5 -0
  166. package/templates/.mcp.json +8 -0
  167. package/templates/AGENTS.md +64 -0
  168. package/templates/CLAUDE.md +1 -0
  169. package/templates/cases/_template.md +57 -0
  170. package/templates/gitignore +13 -0
  171. package/templates/opencode.jsonc +16 -0
  172. package/templates/package.json +31 -0
  173. package/templates/playwright.config.ts +3 -0
  174. package/templates/pnpm-workspace.yaml +5 -0
  175. package/templates/profiles.yaml +42 -0
  176. package/templates/seed.spec.ts +9 -0
  177. package/templates/tsconfig.json +14 -0
  178. package/ui/dist/apple-touch-icon.png +0 -0
  179. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  180. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  181. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  182. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  183. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  184. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  185. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  186. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  187. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  188. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  189. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  190. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  191. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  192. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  193. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  194. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  195. package/ui/dist/assets/dist-BGbe0-Aa.js +1 -0
  196. package/ui/dist/assets/dist-Ba-HC662.js +2 -0
  197. package/ui/dist/assets/dist-BtMLMthJ.js +1 -0
  198. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  199. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  200. package/ui/dist/assets/dist-C-Oyo1ZT.js +1 -0
  201. package/ui/dist/assets/dist-C8MdhqgG.js +1 -0
  202. package/ui/dist/assets/dist-CG1pHn5m.js +1 -0
  203. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  204. package/ui/dist/assets/dist-CmpteMYY.js +9 -0
  205. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  206. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  207. package/ui/dist/assets/dist-DSy3dVf4.js +11 -0
  208. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  209. package/ui/dist/assets/dist-Dq6sW5Ur.js +1 -0
  210. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  211. package/ui/dist/assets/dist-jjxQutEm.js +1 -0
  212. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  213. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  214. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  215. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  216. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  217. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  218. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  219. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  220. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  221. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  222. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  223. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  224. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  225. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  226. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  227. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  228. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  229. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  230. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  231. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  232. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  233. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  234. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  235. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  236. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  237. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  238. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  239. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  240. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  241. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  242. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  243. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  244. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  245. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  246. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  247. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  248. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  249. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  250. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  251. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  252. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  253. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  254. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  255. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  256. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  257. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  258. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  259. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  260. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  261. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  262. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  263. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  264. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  265. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  266. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  267. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  268. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  269. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  270. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  271. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  272. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  273. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  274. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  275. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  276. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  277. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  278. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  279. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  280. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  281. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  282. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  283. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  284. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  285. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  286. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  287. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  288. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  289. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  290. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  291. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  292. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  293. package/ui/dist/favicon.ico +0 -0
  294. package/ui/dist/favicon.svg +11 -0
  295. package/ui/dist/help/ai-tools.png +0 -0
  296. package/ui/dist/help/case-explore.png +0 -0
  297. package/ui/dist/help/case-generate.png +0 -0
  298. package/ui/dist/help/cases-memo.png +0 -0
  299. package/ui/dist/help/profile-form.png +0 -0
  300. package/ui/dist/help/profiles-login.png +0 -0
  301. package/ui/dist/help/reports.png +0 -0
  302. package/ui/dist/help/run-tests.png +0 -0
  303. package/ui/dist/index.html +23 -0
  304. package/ui/dist/logos/claude.svg +1 -0
  305. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,33 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { createApp } from './app.js';
3
+ import { verifyProfiles } from './profiles.js';
4
+ import { jobs } from './jobs.js';
5
+ import { startFileWatcher } from './watch-files.js';
6
+ const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
7
+ const app = createApp({ port });
8
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
9
+ const devOrigin = process.env.ASRUN_UI_DEV_ORIGIN;
10
+ if (devOrigin) {
11
+ console.log(`asrun API (dev, no UI here): http://127.0.0.1:${port}`);
12
+ console.log(`asrun UI: ${devOrigin}`);
13
+ }
14
+ else {
15
+ console.log(`asrun UI: http://127.0.0.1:${port}`);
16
+ }
17
+ startFileWatcher();
18
+ void verifyProfiles();
19
+ });
20
+ server.on('error', (error) => {
21
+ if (error.code === 'EADDRINUSE') {
22
+ console.error(`Port ${port} is already in use. Is the UI already running? Or set ASRUN_UI_PORT.`);
23
+ process.exit(1);
24
+ }
25
+ throw error;
26
+ });
27
+ const shutdown = () => {
28
+ jobs.killAll();
29
+ server.close();
30
+ setTimeout(() => process.exit(0), 300).unref();
31
+ };
32
+ process.on('SIGINT', shutdown);
33
+ process.on('SIGTERM', shutdown);
@@ -0,0 +1,80 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import fs from 'node:fs';
4
+ import type { JobChunk, JobInfo, JobKind } from '../shared/types.js';
5
+ import type { AiCommand, AiTool } from '../shared/ai-commands.js';
6
+ import { AiStreamFormatter } from './ai-stream.js';
7
+ export declare class JobConflictError extends Error {
8
+ }
9
+ export interface JobSpec {
10
+ kind: JobKind;
11
+ profile?: string;
12
+ env?: string;
13
+ /** Shown to the user. */
14
+ command: string;
15
+ /** Executable and arguments: looked up on PATH, spawned without a shell (a Windows `.cmd` shim goes through cmd.exe). */
16
+ file: string;
17
+ args: string[];
18
+ /** Jobs holding the same lock cannot run at the same time. */
19
+ locks: string[];
20
+ acceptsInput: boolean;
21
+ /** The agent CLI whose JSON event stream on stdout is turned into readable log lines. */
22
+ stream?: AiTool;
23
+ /** Set for a test run that overwrites the baselines of these cases. */
24
+ baselineCases?: string[];
25
+ /** Set for a test run: the report folder it writes. */
26
+ reportId?: string;
27
+ /** Set for the AI run of a command on a case: what a later turn of its conversation needs. */
28
+ ai?: {
29
+ tool: AiTool;
30
+ command: AiCommand;
31
+ caseId: string;
32
+ env: string;
33
+ sessionId: string;
34
+ model?: string;
35
+ thinking?: boolean;
36
+ };
37
+ /** The job whose conversation this one continues. */
38
+ continuesJobId?: string;
39
+ /** Runs synchronously right before the final status is published. */
40
+ onFinish?: (job: JobInfo) => void;
41
+ }
42
+ interface JobRecord {
43
+ info: JobInfo;
44
+ spec: JobSpec;
45
+ chunks: JobChunk[];
46
+ bufferedChars: number;
47
+ nextSeq: number;
48
+ emitter: EventEmitter;
49
+ child: ChildProcess | null;
50
+ cancelRequested: boolean;
51
+ logStream: fs.WriteStream;
52
+ formatter: AiStreamFormatter | null;
53
+ heartbeat: NodeJS.Timeout | null;
54
+ }
55
+ declare class JobManager {
56
+ private jobs;
57
+ /** Emits `job` with a JobInfo on every status change of any job. */
58
+ readonly events: EventEmitter<[never]>;
59
+ constructor();
60
+ list(): JobInfo[];
61
+ get(id: string): JobRecord | undefined;
62
+ start(spec: JobSpec): JobInfo;
63
+ write(id: string, text: string): boolean;
64
+ cancel(id: string): boolean;
65
+ /** Stops everything; called when the server shuts down. */
66
+ killAll(): void;
67
+ /** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
68
+ private syncAwaiting;
69
+ private signalGroup;
70
+ /** A block of an AI run's log, with the time it was produced and how the UI should present it. */
71
+ private pushPart;
72
+ private push;
73
+ /** Keeps the chunk for the browser and writes it to the job's log file, which is what is left after the run. */
74
+ private store;
75
+ private finish;
76
+ private pruneFinished;
77
+ private pruneOldLogs;
78
+ }
79
+ export declare const jobs: JobManager;
80
+ export {};
@@ -0,0 +1,317 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+ import { ROOT } from '../../lib/paths.js';
8
+ import { spawnTarget } from '../../lib/command.js';
9
+ import { AiStreamFormatter } from './ai-stream.js';
10
+ const LOG_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
11
+ const MAX_BUFFERED_CHARS = 4_000_000;
12
+ const MAX_FINISHED_JOBS = 30;
13
+ const LOG_RETENTION_MS = 7 * 24 * 3600 * 1000;
14
+ const KILL_GRACE_MS = 5000;
15
+ // An agent that has said nothing for this long gets a "still waiting for <tool call>" line in its log.
16
+ const QUIET_MS = 45_000;
17
+ const HEARTBEAT_MS = 15_000;
18
+ export class JobConflictError extends Error {
19
+ }
20
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
21
+ /** The last `RESULT: ready|needs-input` line the agent said (markdown emphasis around it is fine). */
22
+ function parseResultState(record) {
23
+ const text = record.chunks
24
+ // Only what the agent said counts: a file it read or a page it dumped may quote the same words.
25
+ .filter((chunk) => (chunk.kind ? chunk.kind === 'markdown' : chunk.stream !== 'system'))
26
+ .map((chunk) => chunk.data)
27
+ .join('\n')
28
+ .replace(ANSI, '');
29
+ const matches = [...text.matchAll(/^[\s*`>_-]*RESULT:\s*(ready|needs-input)\b/gm)];
30
+ return matches.at(-1)?.[1] ?? null;
31
+ }
32
+ /**
33
+ * A block of an AI run as the log file keeps it: the same text the UI shows, with the timestamp the UI styles written
34
+ * out, and the output of a call indented under the call it belongs to. The file is read in a terminal or an editor, so
35
+ * it has to make sense without the colors.
36
+ */
37
+ function logFileText(part) {
38
+ const stamp = `[${new Date(part.at).toTimeString().slice(0, 8)}] `;
39
+ // What the agent said is separated by a blank line: it is the answer, not another line of the action above it.
40
+ if (part.kind === 'markdown')
41
+ return `\n${part.data}\n`;
42
+ if (part.kind === 'result' || part.kind === 'reasoning') {
43
+ const mark = part.kind === 'reasoning' ? '~ ' : '';
44
+ return part.data
45
+ .split('\n')
46
+ .map((line, index) => ` ${index === 0 ? mark : ''}${line}\n`)
47
+ .join('');
48
+ }
49
+ return `${stamp}${part.data}\n`;
50
+ }
51
+ class JobManager {
52
+ jobs = new Map();
53
+ /** Emits `job` with a JobInfo on every status change of any job. */
54
+ events = new EventEmitter();
55
+ constructor() {
56
+ this.events.setMaxListeners(0);
57
+ this.pruneOldLogs();
58
+ }
59
+ list() {
60
+ return [...this.jobs.values()].map((record) => record.info).reverse();
61
+ }
62
+ get(id) {
63
+ return this.jobs.get(id);
64
+ }
65
+ start(spec) {
66
+ for (const record of this.jobs.values()) {
67
+ if (record.info.status !== 'running')
68
+ continue;
69
+ const clash = record.spec.locks.find((lock) => spec.locks.includes(lock));
70
+ if (clash) {
71
+ throw new JobConflictError(`Cannot start: "${record.info.command}" is already running (${clash}). Wait for it to finish or cancel it.`);
72
+ }
73
+ }
74
+ const id = crypto.randomBytes(6).toString('hex');
75
+ fs.mkdirSync(LOG_DIR, { recursive: true });
76
+ const record = {
77
+ info: {
78
+ id,
79
+ kind: spec.kind,
80
+ profile: spec.profile,
81
+ env: spec.env,
82
+ command: spec.command,
83
+ status: 'running',
84
+ exitCode: null,
85
+ signal: null,
86
+ startedAt: new Date().toISOString(),
87
+ endedAt: null,
88
+ acceptsInput: spec.acceptsInput,
89
+ ...(spec.baselineCases && { baselineCases: spec.baselineCases }),
90
+ ...(spec.reportId && { reportId: spec.reportId }),
91
+ ...(spec.ai && {
92
+ caseId: spec.ai.caseId,
93
+ aiCommand: spec.ai.command,
94
+ sessionId: spec.ai.sessionId,
95
+ ...(spec.ai.model && { aiModel: spec.ai.model }),
96
+ resultState: null,
97
+ }),
98
+ ...(spec.continuesJobId && { continuesJobId: spec.continuesJobId }),
99
+ },
100
+ spec,
101
+ chunks: [],
102
+ bufferedChars: 0,
103
+ nextSeq: 0,
104
+ emitter: new EventEmitter(),
105
+ child: null,
106
+ cancelRequested: false,
107
+ logStream: fs.createWriteStream(path.join(LOG_DIR, `${id}.log`)),
108
+ formatter: spec.stream ? new AiStreamFormatter(spec.stream) : null,
109
+ heartbeat: null,
110
+ };
111
+ record.emitter.setMaxListeners(0);
112
+ this.jobs.set(id, record);
113
+ this.push(record, 'system', `$ ${spec.command}\n`);
114
+ // A CLI installed globally is a `.cmd` shim on Windows, which spawn cannot start on its own (see lib/command.ts).
115
+ const target = spawnTarget(spec.file, spec.args);
116
+ if (target === null) {
117
+ this.finish(record, null, null, `Failed to start: "${spec.file}" was not found on PATH. Install it, or choose the other tool.`);
118
+ return record.info;
119
+ }
120
+ let child;
121
+ try {
122
+ child = spawn(target.file, target.args, {
123
+ cwd: ROOT,
124
+ // Off Windows the group is what `cancel` signals. On Windows a detached child loses the output of a `.cmd`
125
+ // shim, and the tree is ended with taskkill instead (see signalGroup).
126
+ detached: process.platform !== 'win32',
127
+ stdio: [spec.acceptsInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
128
+ env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: undefined },
129
+ windowsHide: true,
130
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
131
+ });
132
+ }
133
+ catch (error) {
134
+ this.finish(record, null, null, `Failed to start: ${error.message}`);
135
+ return record.info;
136
+ }
137
+ record.child = child;
138
+ for (const stream of ['stdout', 'stderr']) {
139
+ const decoder = new StringDecoder('utf8');
140
+ const formatted = stream === 'stdout' ? record.formatter : null;
141
+ const emit = (text) => {
142
+ if (formatted)
143
+ formatted.feed(text).forEach((part) => this.pushPart(record, part));
144
+ else
145
+ this.push(record, stream, text);
146
+ this.syncAwaiting(record);
147
+ };
148
+ child[stream]?.on('data', (buffer) => {
149
+ const text = decoder.write(buffer);
150
+ if (text)
151
+ emit(text);
152
+ });
153
+ child[stream]?.on('end', () => {
154
+ const rest = decoder.end();
155
+ if (rest)
156
+ emit(rest);
157
+ formatted?.end().forEach((part) => this.pushPart(record, part));
158
+ });
159
+ }
160
+ if (record.formatter) {
161
+ const formatter = record.formatter;
162
+ record.heartbeat = setInterval(() => {
163
+ const line = formatter.waiting(QUIET_MS);
164
+ if (line)
165
+ this.pushPart(record, line);
166
+ }, HEARTBEAT_MS);
167
+ record.heartbeat.unref();
168
+ }
169
+ child.stdin?.on('error', () => { });
170
+ let finished = false;
171
+ child.on('error', (error) => {
172
+ if (finished)
173
+ return;
174
+ finished = true;
175
+ this.finish(record, null, null, `Failed to start: ${error.message}`);
176
+ });
177
+ child.on('close', (code, signal) => {
178
+ if (finished)
179
+ return;
180
+ finished = true;
181
+ this.finish(record, code, signal);
182
+ });
183
+ this.events.emit('job', record.info);
184
+ return record.info;
185
+ }
186
+ write(id, text) {
187
+ const record = this.jobs.get(id);
188
+ if (!record || record.info.status !== 'running' || !record.info.acceptsInput)
189
+ return false;
190
+ return record.child?.stdin?.write(text) ?? false;
191
+ }
192
+ cancel(id) {
193
+ const record = this.jobs.get(id);
194
+ const pid = record?.child?.pid;
195
+ if (!record || record.info.status !== 'running' || !pid)
196
+ return false;
197
+ record.cancelRequested = true;
198
+ this.signalGroup(pid, 'SIGTERM');
199
+ setTimeout(() => {
200
+ if (record.info.status === 'running')
201
+ this.signalGroup(pid, 'SIGKILL');
202
+ }, KILL_GRACE_MS).unref();
203
+ return true;
204
+ }
205
+ /** Stops everything; called when the server shuts down. */
206
+ killAll() {
207
+ for (const record of this.jobs.values()) {
208
+ const pid = record.child?.pid;
209
+ if (record.info.status === 'running' && pid)
210
+ this.signalGroup(pid, 'SIGTERM');
211
+ }
212
+ }
213
+ /** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
214
+ syncAwaiting(record) {
215
+ const { info } = record;
216
+ if (info.status !== 'running' || !record.formatter)
217
+ return;
218
+ const now = record.formatter.awaiting;
219
+ const before = info.awaitingUser ?? null;
220
+ if (!now && !before)
221
+ return;
222
+ if (now && before && before.since === new Date(now.since).toISOString())
223
+ return;
224
+ info.awaitingUser = now ? { message: now.message, since: new Date(now.since).toISOString() } : null;
225
+ this.events.emit('job', info);
226
+ }
227
+ signalGroup(pid, signal) {
228
+ if (process.platform === 'win32') {
229
+ // Windows has no process groups (a negative pid throws ESRCH), so the whole tree is ended at once:
230
+ // cmd.exe, the agent CLI it started, and anything below them.
231
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
232
+ return;
233
+ }
234
+ try {
235
+ process.kill(-pid, signal);
236
+ }
237
+ catch {
238
+ // Already gone.
239
+ }
240
+ }
241
+ /** A block of an AI run's log, with the time it was produced and how the UI should present it. */
242
+ pushPart(record, part) {
243
+ const stream = part.kind === 'error' ? 'stderr' : part.kind === 'markdown' || part.kind === 'result' ? 'stdout' : 'system';
244
+ const chunk = {
245
+ seq: record.nextSeq++,
246
+ stream,
247
+ data: part.data,
248
+ kind: part.kind,
249
+ at: new Date(part.at).toISOString(),
250
+ ...(part.tool && { tool: part.tool }),
251
+ };
252
+ this.store(record, chunk, logFileText(part));
253
+ }
254
+ push(record, stream, data) {
255
+ const chunk = { seq: record.nextSeq++, stream, data };
256
+ this.store(record, chunk, stream === 'stderr' ? `[stderr] ${data}` : data);
257
+ }
258
+ /** Keeps the chunk for the browser and writes it to the job's log file, which is what is left after the run. */
259
+ store(record, chunk, fileText) {
260
+ record.chunks.push(chunk);
261
+ record.bufferedChars += chunk.data.length;
262
+ while (record.bufferedChars > MAX_BUFFERED_CHARS && record.chunks.length > 1) {
263
+ record.bufferedChars -= record.chunks.shift().data.length;
264
+ }
265
+ record.logStream.write(fileText);
266
+ record.emitter.emit('chunk', chunk);
267
+ }
268
+ finish(record, code, signal, note) {
269
+ const { info } = record;
270
+ if (record.heartbeat)
271
+ clearInterval(record.heartbeat);
272
+ info.awaitingUser = null;
273
+ info.exitCode = code;
274
+ info.signal = signal;
275
+ info.endedAt = new Date().toISOString();
276
+ info.status = record.cancelRequested
277
+ ? 'cancelled'
278
+ : code === 0
279
+ ? 'succeeded'
280
+ : 'failed';
281
+ const summary = note ??
282
+ (info.status === 'cancelled'
283
+ ? '[cancelled]'
284
+ : signal
285
+ ? `[process killed by ${signal}]`
286
+ : `[process exited with code ${code}]`);
287
+ if (record.spec.ai && info.status === 'succeeded')
288
+ info.resultState = parseResultState(record);
289
+ this.push(record, 'system', `\n${summary}\n`);
290
+ record.logStream.end();
291
+ try {
292
+ record.spec.onFinish?.(info);
293
+ }
294
+ catch (error) {
295
+ console.error('onFinish failed:', error);
296
+ }
297
+ record.emitter.emit('status', info);
298
+ this.events.emit('job', info);
299
+ this.pruneFinished();
300
+ }
301
+ pruneFinished() {
302
+ const finished = [...this.jobs.values()].filter((record) => record.info.status !== 'running');
303
+ for (const record of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) {
304
+ this.jobs.delete(record.info.id);
305
+ }
306
+ }
307
+ pruneOldLogs() {
308
+ if (!fs.existsSync(LOG_DIR))
309
+ return;
310
+ for (const name of fs.readdirSync(LOG_DIR)) {
311
+ const file = path.join(LOG_DIR, name);
312
+ if (Date.now() - fs.statSync(file).mtimeMs > LOG_RETENTION_MS)
313
+ fs.rmSync(file, { force: true });
314
+ }
315
+ }
316
+ }
317
+ export const jobs = new JobManager();
@@ -0,0 +1,35 @@
1
+ import type { AddProfileRequest, EditProfileRequest, EditProfileResult, ProfileInfo, ProfilesResponse } from '../shared/types.js';
2
+ export declare function listProfileRows(): ProfilesResponse;
3
+ /** Opens each saved session on the real site (read-only) and records whether it is still logged in. */
4
+ export declare function verifyProfiles(only?: {
5
+ ids?: string[];
6
+ env?: string;
7
+ }): Promise<void>;
8
+ export declare class ProfileError extends Error {
9
+ readonly status: 400 | 409;
10
+ constructor(message: string, status: 400 | 409);
11
+ }
12
+ /**
13
+ * Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
14
+ * existing entries stay as they are.
15
+ */
16
+ export declare function addProfile(request: AddProfileRequest, file?: string): ProfileInfo;
17
+ /**
18
+ * Deletes the saved login of one profile, so it has to log in again. The file holds the session cookies
19
+ * and is never read here, only removed.
20
+ */
21
+ export declare function forgetSession(id: string, file?: string): ProfileInfo;
22
+ /**
23
+ * Removes one profile from profiles.yaml and deletes its saved login. Only that entry is written, so the
24
+ * comments and the other entries stay in the file. Screenshot baselines named after the id stay on disk:
25
+ * they are files of the cases, not of the profile.
26
+ */
27
+ export declare function deleteProfile(id: string, file?: string): void;
28
+ /**
29
+ * Changes the fields of one existing profile. Only the fields that actually changed are written, so
30
+ * comments on the others stay in the file. When the site address changes, the saved login belongs to
31
+ * the old site and is deleted: the profile has to log in again on the new one.
32
+ */
33
+ export declare function editProfile(request: EditProfileRequest & {
34
+ id: string;
35
+ }, file?: string): EditProfileResult;