@asrunjs/asrun 0.0.0-stage → 0.2.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/.claude/commands/explore-case.md +15 -0
- package/.claude/commands/gen-spec.md +13 -0
- package/.claude/commands/heal-spec.md +23 -0
- package/.claude/commands/make-case.md +8 -0
- package/.claude/settings.json +5 -0
- package/.claude/skills/exploring-cases/SKILL.md +132 -0
- package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
- package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
- package/.claude/skills/exploring-cases/references/readiness.md +137 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
- package/.claude/skills/generating-specs/SKILL.md +207 -0
- package/.claude/skills/generating-specs/references/conventions.md +148 -0
- package/.claude/skills/making-cases/SKILL.md +64 -0
- package/.claude/skills/making-cases/references/gaps.md +36 -0
- package/.claude/skills/making-cases/references/normalizing.md +38 -0
- package/.claude/skills/making-cases/references/splitting.md +28 -0
- package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
- package/AGENTS.md +154 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +410 -3
- package/bin/asrun.mjs +49 -0
- package/dist/lib/account-lock.d.ts +6 -0
- package/dist/lib/account-lock.js +52 -0
- package/dist/lib/assertions.d.ts +14 -0
- package/dist/lib/assertions.js +39 -0
- package/dist/lib/baselines.d.ts +43 -0
- package/dist/lib/baselines.js +108 -0
- package/dist/lib/case-id.d.ts +11 -0
- package/dist/lib/case-id.js +17 -0
- package/dist/lib/case-ops.d.ts +26 -0
- package/dist/lib/case-ops.js +110 -0
- package/dist/lib/cases.d.ts +29 -0
- package/dist/lib/cases.js +124 -0
- package/dist/lib/cli.d.ts +10 -0
- package/dist/lib/cli.js +18 -0
- package/dist/lib/downloads.d.ts +42 -0
- package/dist/lib/downloads.js +84 -0
- package/dist/lib/email.d.ts +10 -0
- package/dist/lib/email.js +19 -0
- package/dist/lib/ffmpeg.d.ts +8 -0
- package/dist/lib/ffmpeg.js +50 -0
- package/dist/lib/fixtures.d.ts +40 -0
- package/dist/lib/fixtures.js +149 -0
- package/dist/lib/node-cli.d.ts +11 -0
- package/dist/lib/node-cli.js +26 -0
- package/dist/lib/paths.d.ts +25 -0
- package/dist/lib/paths.js +88 -0
- package/dist/lib/pdf.d.ts +71 -0
- package/dist/lib/pdf.js +140 -0
- package/dist/lib/profiles.d.ts +38 -0
- package/dist/lib/profiles.js +121 -0
- package/dist/lib/ready.d.ts +36 -0
- package/dist/lib/ready.js +53 -0
- package/dist/lib/records.d.ts +33 -0
- package/dist/lib/records.js +61 -0
- package/dist/lib/run-filters.d.ts +19 -0
- package/dist/lib/run-filters.js +30 -0
- package/dist/lib/run-summary.d.ts +90 -0
- package/dist/lib/run-summary.js +35 -0
- package/dist/lib/session.d.ts +18 -0
- package/dist/lib/session.js +45 -0
- package/dist/lib/summary-reporter.d.ts +16 -0
- package/dist/lib/summary-reporter.js +146 -0
- package/dist/lib/targets.d.ts +27 -0
- package/dist/lib/targets.js +51 -0
- package/dist/lib/ui-build.d.ts +10 -0
- package/dist/lib/ui-build.js +30 -0
- package/dist/lib/values.d.ts +29 -0
- package/dist/lib/values.js +85 -0
- package/dist/lib/write-gate.d.ts +41 -0
- package/dist/lib/write-gate.js +129 -0
- package/dist/playwright.config.d.ts +2 -0
- package/dist/playwright.config.js +92 -0
- package/dist/scripts/build.d.ts +1 -0
- package/dist/scripts/build.js +20 -0
- package/dist/scripts/check-cases.d.ts +1 -0
- package/dist/scripts/check-cases.js +81 -0
- package/dist/scripts/cleanup.d.ts +1 -0
- package/dist/scripts/cleanup.js +25 -0
- package/dist/scripts/cli.d.ts +1 -0
- package/dist/scripts/cli.js +28 -0
- package/dist/scripts/doctor.d.ts +1 -0
- package/dist/scripts/doctor.js +185 -0
- package/dist/scripts/explore.d.ts +1 -0
- package/dist/scripts/explore.js +54 -0
- package/dist/scripts/go.mjs +188 -0
- package/dist/scripts/heal-evidence.mjs +143 -0
- package/dist/scripts/init.d.ts +16 -0
- package/dist/scripts/init.js +284 -0
- package/dist/scripts/lint-specs.d.ts +1 -0
- package/dist/scripts/lint-specs.js +138 -0
- package/dist/scripts/login.d.ts +1 -0
- package/dist/scripts/login.js +47 -0
- package/dist/scripts/new-case.d.ts +1 -0
- package/dist/scripts/new-case.js +19 -0
- package/dist/scripts/render-pdf.d.ts +1 -0
- package/dist/scripts/render-pdf.js +44 -0
- package/dist/scripts/report.d.ts +1 -0
- package/dist/scripts/report.js +14 -0
- package/dist/scripts/review-baselines.d.ts +1 -0
- package/dist/scripts/review-baselines.js +30 -0
- package/dist/scripts/run.d.ts +1 -0
- package/dist/scripts/run.js +423 -0
- package/dist/scripts/status.d.ts +1 -0
- package/dist/scripts/status.js +31 -0
- package/dist/scripts/ui-build.d.ts +1 -0
- package/dist/scripts/ui-build.js +7 -0
- package/dist/scripts/ui-dev.d.ts +1 -0
- package/dist/scripts/ui-dev.js +3 -0
- package/dist/scripts/ui.d.ts +1 -0
- package/dist/scripts/ui.js +13 -0
- package/dist/ui/dev.d.ts +1 -0
- package/dist/ui/dev.js +36 -0
- package/dist/ui/server/actions.d.ts +91 -0
- package/dist/ui/server/actions.js +352 -0
- package/dist/ui/server/ai-stream.d.ts +41 -0
- package/dist/ui/server/ai-stream.js +202 -0
- package/dist/ui/server/app.d.ts +4 -0
- package/dist/ui/server/app.js +333 -0
- package/dist/ui/server/cases.d.ts +23 -0
- package/dist/ui/server/cases.js +170 -0
- package/dist/ui/server/files.d.ts +5 -0
- package/dist/ui/server/files.js +67 -0
- package/dist/ui/server/index.d.ts +1 -0
- package/dist/ui/server/index.js +33 -0
- package/dist/ui/server/jobs.d.ts +74 -0
- package/dist/ui/server/jobs.js +263 -0
- package/dist/ui/server/profiles.d.ts +16 -0
- package/dist/ui/server/profiles.js +195 -0
- package/dist/ui/server/watch-files.d.ts +4 -0
- package/dist/ui/server/watch-files.js +36 -0
- package/dist/ui/shared/ai-commands.d.ts +78 -0
- package/dist/ui/shared/ai-commands.js +154 -0
- package/dist/ui/shared/case-id.d.ts +1 -0
- package/dist/ui/shared/case-id.js +1 -0
- package/dist/ui/shared/jobs.d.ts +6 -0
- package/dist/ui/shared/jobs.js +7 -0
- package/dist/ui/shared/product.d.ts +3 -0
- package/dist/ui/shared/product.js +3 -0
- package/dist/ui/shared/run-filters.d.ts +1 -0
- package/dist/ui/shared/run-filters.js +1 -0
- package/dist/ui/shared/site-address.d.ts +6 -0
- package/dist/ui/shared/site-address.js +11 -0
- package/dist/ui/shared/types.d.ts +257 -0
- package/dist/ui/shared/types.js +1 -0
- package/docs/theme-guide.html +692 -0
- package/package.json +98 -3
- package/templates/.claude/commands/explore-case.md +21 -0
- package/templates/.claude/commands/gen-spec.md +20 -0
- package/templates/.claude/commands/heal-spec.md +30 -0
- package/templates/.claude/commands/make-case.md +12 -0
- package/templates/.claude/settings.json +5 -0
- package/templates/.mcp.json +8 -0
- package/templates/AGENTS.md +45 -0
- package/templates/CLAUDE.md +1 -0
- package/templates/cases/_template.md +63 -0
- package/templates/gitignore +13 -0
- package/templates/opencode.jsonc +11 -0
- package/templates/package.json +31 -0
- package/templates/playwright.config.ts +3 -0
- package/templates/pnpm-workspace.yaml +5 -0
- package/templates/profiles.yaml +29 -0
- package/templates/seed.spec.ts +9 -0
- package/templates/tsconfig.json +14 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
- package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
- package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
- package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
- package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
- package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
- package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
- package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
- package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
- package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
- package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
- package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
- package/ui/dist/assets/css-CnaYoQib.js +1 -0
- package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
- package/ui/dist/assets/d-DWkQyNnU.js +1 -0
- package/ui/dist/assets/diff-ChtP43wD.js +1 -0
- package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
- package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
- package/ui/dist/assets/dist-BiE4suo9.js +1 -0
- package/ui/dist/assets/dist-BqAROxzI.js +1 -0
- package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
- package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
- package/ui/dist/assets/dist-BzulKuec.js +1 -0
- package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
- package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
- package/ui/dist/assets/dist-DDJNvivM.js +1 -0
- package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
- package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
- package/ui/dist/assets/dist-DX8knLVr.js +1 -0
- package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
- package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
- package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
- package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
- package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
- package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
- package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
- package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
- package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
- package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
- package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
- package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
- package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
- package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
- package/ui/dist/assets/forth-D6RzAl64.js +1 -0
- package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
- package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
- package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
- package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
- package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
- package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
- package/ui/dist/assets/http-CLVgA2GD.js +1 -0
- package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
- package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
- package/ui/dist/assets/index-I7uU93kk.js +294 -0
- package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
- package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
- package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
- package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
- package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
- package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
- package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
- package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
- package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
- package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
- package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
- package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
- package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
- package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
- package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
- package/ui/dist/assets/oz-BwTct_5T.js +1 -0
- package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
- package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
- package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
- package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
- package/ui/dist/assets/properties-C357ku5U.js +1 -0
- package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
- package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
- package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
- package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
- package/ui/dist/assets/q-B9NhS7L_.js +1 -0
- package/ui/dist/assets/r-CxDmU1I4.js +1 -0
- package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
- package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
- package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
- package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
- package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
- package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
- package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
- package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
- package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
- package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
- package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
- package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
- package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
- package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
- package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
- package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
- package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
- package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
- package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
- package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
- package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
- package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
- package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
- package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
- package/ui/dist/assets/vb-BthdM_4N.js +1 -0
- package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
- package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
- package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
- package/ui/dist/assets/vhdl-kS471soi.js +1 -0
- package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
- package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
- package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
- package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.svg +11 -0
- package/ui/dist/index.html +17 -0
- package/ui/dist/logos/claude.svg +1 -0
- package/ui/dist/logos/opencode.svg +1 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { parse } from 'yaml';
|
|
6
|
+
import { isValidCaseId } from '../../lib/case-id.js';
|
|
7
|
+
import { assertValidNewId, CaseError, caseFilePath, deleteCase as deleteCaseFiles, getCaseImpact, renameCase as renameCaseFiles, specFilePath, } from '../../lib/case-ops.js';
|
|
8
|
+
import { listCaseFileIds, parseCaseMeta } from '../../lib/cases.js';
|
|
9
|
+
import { scriptArgs } from '../../lib/node-cli.js';
|
|
10
|
+
import { ROOT } from '../../lib/paths.js';
|
|
11
|
+
const MAX_BYTES = 1_000_000;
|
|
12
|
+
export { CaseError, isValidCaseId };
|
|
13
|
+
function fileFor(id) {
|
|
14
|
+
if (!isValidCaseId(id))
|
|
15
|
+
throw new CaseError(`Invalid case id "${id}".`, 400);
|
|
16
|
+
return caseFilePath(id);
|
|
17
|
+
}
|
|
18
|
+
function frontMatter(text) {
|
|
19
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
20
|
+
if (!match)
|
|
21
|
+
return {};
|
|
22
|
+
try {
|
|
23
|
+
const value = parse(match[1]);
|
|
24
|
+
return value && typeof value === 'object' ? value : {};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const asString = (value) => (typeof value === 'string' ? value : null);
|
|
31
|
+
export const listCaseIds = listCaseFileIds;
|
|
32
|
+
export function listCases() {
|
|
33
|
+
return listCaseIds().map((id) => {
|
|
34
|
+
const file = fileFor(id);
|
|
35
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
36
|
+
const meta = frontMatter(text);
|
|
37
|
+
let parsed = null;
|
|
38
|
+
let problem = null;
|
|
39
|
+
try {
|
|
40
|
+
parsed = parseCaseMeta(id, text);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
problem = error.message;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
title: asString(meta.title),
|
|
48
|
+
login: asString(meta.login),
|
|
49
|
+
suites: parsed?.suites ?? [],
|
|
50
|
+
writes: parsed?.writes ?? [],
|
|
51
|
+
writesNote: parsed?.writesNote ?? null,
|
|
52
|
+
envs: parsed?.envs ?? null,
|
|
53
|
+
problem,
|
|
54
|
+
group: id.includes('/') ? id.slice(0, id.indexOf('/')) : null,
|
|
55
|
+
hasSpec: fs.existsSync(specFilePath(id)),
|
|
56
|
+
updatedAt: fs.statSync(file).mtime.toISOString(),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function readCase(id) {
|
|
61
|
+
const file = fileFor(id);
|
|
62
|
+
if (!fs.existsSync(file))
|
|
63
|
+
throw new CaseError(`Case "${id}" does not exist.`, 404);
|
|
64
|
+
return { id, content: fs.readFileSync(file, 'utf8'), mtimeMs: fs.statSync(file).mtimeMs };
|
|
65
|
+
}
|
|
66
|
+
/** The path comes from the case id only, never from the browser. */
|
|
67
|
+
function existingSpecFile(id) {
|
|
68
|
+
if (!isValidCaseId(id))
|
|
69
|
+
throw new CaseError(`Invalid case id "${id}".`, 400);
|
|
70
|
+
const file = specFilePath(id);
|
|
71
|
+
if (!fs.existsSync(file))
|
|
72
|
+
throw new CaseError(`Case "${id}" has no spec yet.`, 404);
|
|
73
|
+
return file;
|
|
74
|
+
}
|
|
75
|
+
export function readSpec(id) {
|
|
76
|
+
const file = existingSpecFile(id);
|
|
77
|
+
const stat = fs.statSync(file);
|
|
78
|
+
if (stat.size > MAX_BYTES)
|
|
79
|
+
throw new CaseError('Spec file is too large to show.', 400);
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
file: path.relative(ROOT, file),
|
|
83
|
+
path: file,
|
|
84
|
+
content: fs.readFileSync(file, 'utf8'),
|
|
85
|
+
mtimeMs: stat.mtimeMs,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Writes only if the file is unchanged since the editor loaded it (an agent or an external editor may change it too). */
|
|
89
|
+
export function writeSpec(id, content, expectedMtimeMs) {
|
|
90
|
+
const file = existingSpecFile(id);
|
|
91
|
+
if (Buffer.byteLength(content) > MAX_BYTES)
|
|
92
|
+
throw new CaseError('Spec file is too large.', 400);
|
|
93
|
+
if (fs.statSync(file).mtimeMs !== expectedMtimeMs) {
|
|
94
|
+
throw new CaseError('The file changed on disk since you opened it. Reload it (copy your edits first) to continue.', 409);
|
|
95
|
+
}
|
|
96
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
97
|
+
fs.writeFileSync(temp, content);
|
|
98
|
+
fs.renameSync(temp, file);
|
|
99
|
+
return readSpec(id);
|
|
100
|
+
}
|
|
101
|
+
/** The command that opens a file with the application the operating system has registered for it. */
|
|
102
|
+
function openCommand(file) {
|
|
103
|
+
if (process.platform === 'darwin')
|
|
104
|
+
return { command: 'open', args: [file] };
|
|
105
|
+
if (process.platform === 'win32')
|
|
106
|
+
return { command: 'cmd', args: ['/c', 'start', '""', file] };
|
|
107
|
+
return { command: 'xdg-open', args: [file] };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Opens the spec of a case in the default application of the system. Resolves once the opener has handed the file over
|
|
111
|
+
* (or is still running after a moment, as `xdg-open` can be); rejects when there is no opener or it fails.
|
|
112
|
+
*/
|
|
113
|
+
export function openSpec(id) {
|
|
114
|
+
const { command, args } = openCommand(existingSpecFile(id));
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const fail = (reason) => reject(new CaseError(`Could not open the spec: ${reason}`, 400));
|
|
117
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
child.unref();
|
|
120
|
+
resolve();
|
|
121
|
+
}, 3000);
|
|
122
|
+
child.on('error', (error) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
fail(error.message);
|
|
125
|
+
});
|
|
126
|
+
child.on('exit', (code) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
if (code === 0)
|
|
129
|
+
resolve();
|
|
130
|
+
else
|
|
131
|
+
fail(`"${command}" exited with code ${code}.`);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Writes only if the file is unchanged since the editor loaded it (an agent may be editing it too). */
|
|
136
|
+
export function writeCase(id, content, expectedMtimeMs) {
|
|
137
|
+
const file = fileFor(id);
|
|
138
|
+
if (!fs.existsSync(file))
|
|
139
|
+
throw new CaseError(`Case "${id}" does not exist.`, 404);
|
|
140
|
+
if (Buffer.byteLength(content) > MAX_BYTES)
|
|
141
|
+
throw new CaseError('Case file is too large.', 400);
|
|
142
|
+
if (fs.statSync(file).mtimeMs !== expectedMtimeMs) {
|
|
143
|
+
throw new CaseError('The file changed on disk since you opened it. Reload it (copy your edits first) to continue.', 409);
|
|
144
|
+
}
|
|
145
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
146
|
+
fs.writeFileSync(temp, content);
|
|
147
|
+
fs.renameSync(temp, file);
|
|
148
|
+
return readCase(id);
|
|
149
|
+
}
|
|
150
|
+
/** Reuses `pnpm case:new` so the template stays in one place. */
|
|
151
|
+
export async function createCase(id) {
|
|
152
|
+
assertValidNewId(id);
|
|
153
|
+
if (fs.existsSync(fileFor(id)))
|
|
154
|
+
throw new CaseError(`Case "${id}" already exists.`, 409);
|
|
155
|
+
await promisify(execFile)(process.execPath, [...scriptArgs('new-case.ts'), id], {
|
|
156
|
+
cwd: ROOT,
|
|
157
|
+
});
|
|
158
|
+
return readCase(id);
|
|
159
|
+
}
|
|
160
|
+
export const caseImpact = (id) => {
|
|
161
|
+
fileFor(id);
|
|
162
|
+
if (!fs.existsSync(caseFilePath(id)))
|
|
163
|
+
throw new CaseError(`Case "${id}" does not exist.`, 404);
|
|
164
|
+
return getCaseImpact(id);
|
|
165
|
+
};
|
|
166
|
+
export const renameCase = (id, to) => {
|
|
167
|
+
renameCaseFiles(id, to);
|
|
168
|
+
return readCase(to);
|
|
169
|
+
};
|
|
170
|
+
export const deleteCase = (id) => deleteCaseFiles(id);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
/** Resolves `relative` under `base`; returns null if the result escapes `base` (also via symlinks). */
|
|
3
|
+
export declare function resolveInside(base: string, relative: string): string | null;
|
|
4
|
+
/** Serves a file with HTTP Range support, which videos need for seeking. */
|
|
5
|
+
export declare function sendFile(c: Context, file: string): Response | Promise<Response>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
const MIME = {
|
|
5
|
+
'.html': 'text/html; charset=utf-8',
|
|
6
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
7
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
8
|
+
'.css': 'text/css; charset=utf-8',
|
|
9
|
+
'.json': 'application/json; charset=utf-8',
|
|
10
|
+
'.webmanifest': 'application/manifest+json',
|
|
11
|
+
'.svg': 'image/svg+xml',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.webm': 'video/webm',
|
|
16
|
+
'.mp4': 'video/mp4',
|
|
17
|
+
'.zip': 'application/zip',
|
|
18
|
+
'.ttf': 'font/ttf',
|
|
19
|
+
'.woff2': 'font/woff2',
|
|
20
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
21
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
22
|
+
};
|
|
23
|
+
/** Resolves `relative` under `base`; returns null if the result escapes `base` (also via symlinks). */
|
|
24
|
+
export function resolveInside(base, relative) {
|
|
25
|
+
const realBase = fs.realpathSync(base);
|
|
26
|
+
const target = path.resolve(realBase, '.' + path.sep + relative);
|
|
27
|
+
let real;
|
|
28
|
+
try {
|
|
29
|
+
real = fs.realpathSync(target);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return real === realBase || real.startsWith(realBase + path.sep) ? real : null;
|
|
35
|
+
}
|
|
36
|
+
/** Serves a file with HTTP Range support, which videos need for seeking. */
|
|
37
|
+
export function sendFile(c, file) {
|
|
38
|
+
const stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
39
|
+
if (!stat?.isFile())
|
|
40
|
+
return c.notFound();
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
|
|
43
|
+
'Accept-Ranges': 'bytes',
|
|
44
|
+
'Cache-Control': 'no-cache',
|
|
45
|
+
};
|
|
46
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '');
|
|
47
|
+
if (range && (range[1] || range[2])) {
|
|
48
|
+
const size = stat.size;
|
|
49
|
+
const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
|
|
50
|
+
const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
|
|
51
|
+
if (start > end || start >= size) {
|
|
52
|
+
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } });
|
|
53
|
+
}
|
|
54
|
+
return new Response(Readable.toWeb(fs.createReadStream(file, { start, end })), {
|
|
55
|
+
status: 206,
|
|
56
|
+
headers: {
|
|
57
|
+
...headers,
|
|
58
|
+
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
59
|
+
'Content-Length': String(end - start + 1),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return new Response(Readable.toWeb(fs.createReadStream(file)), {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: { ...headers, 'Content-Length': String(stat.size) },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,74 @@
|
|
|
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, spawned without a shell. */
|
|
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
|
+
};
|
|
35
|
+
/** The job whose conversation this one continues. */
|
|
36
|
+
continuesJobId?: string;
|
|
37
|
+
/** Runs synchronously right before the final status is published. */
|
|
38
|
+
onFinish?: (job: JobInfo) => void;
|
|
39
|
+
}
|
|
40
|
+
interface JobRecord {
|
|
41
|
+
info: JobInfo;
|
|
42
|
+
spec: JobSpec;
|
|
43
|
+
chunks: JobChunk[];
|
|
44
|
+
bufferedChars: number;
|
|
45
|
+
nextSeq: number;
|
|
46
|
+
emitter: EventEmitter;
|
|
47
|
+
child: ChildProcess | null;
|
|
48
|
+
cancelRequested: boolean;
|
|
49
|
+
logStream: fs.WriteStream;
|
|
50
|
+
formatter: AiStreamFormatter | null;
|
|
51
|
+
heartbeat: NodeJS.Timeout | null;
|
|
52
|
+
}
|
|
53
|
+
declare class JobManager {
|
|
54
|
+
private jobs;
|
|
55
|
+
/** Emits `job` with a JobInfo on every status change of any job. */
|
|
56
|
+
readonly events: EventEmitter<[never]>;
|
|
57
|
+
constructor();
|
|
58
|
+
list(): JobInfo[];
|
|
59
|
+
get(id: string): JobRecord | undefined;
|
|
60
|
+
start(spec: JobSpec): JobInfo;
|
|
61
|
+
write(id: string, text: string): boolean;
|
|
62
|
+
cancel(id: string): boolean;
|
|
63
|
+
/** Stops everything; called when the server shuts down. */
|
|
64
|
+
killAll(): void;
|
|
65
|
+
/** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
|
|
66
|
+
private syncAwaiting;
|
|
67
|
+
private signalGroup;
|
|
68
|
+
private push;
|
|
69
|
+
private finish;
|
|
70
|
+
private pruneFinished;
|
|
71
|
+
private pruneOldLogs;
|
|
72
|
+
}
|
|
73
|
+
export declare const jobs: JobManager;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { spawn } 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 { AiStreamFormatter } from './ai-stream.js';
|
|
9
|
+
const LOG_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
|
|
10
|
+
const MAX_BUFFERED_CHARS = 4_000_000;
|
|
11
|
+
const MAX_FINISHED_JOBS = 30;
|
|
12
|
+
const LOG_RETENTION_MS = 7 * 24 * 3600 * 1000;
|
|
13
|
+
const KILL_GRACE_MS = 5000;
|
|
14
|
+
// An agent that has said nothing for this long gets a "still waiting for <tool call>" line in its log.
|
|
15
|
+
const QUIET_MS = 45_000;
|
|
16
|
+
const HEARTBEAT_MS = 15_000;
|
|
17
|
+
export class JobConflictError extends Error {
|
|
18
|
+
}
|
|
19
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
20
|
+
/** The last `RESULT: ready|needs-input` line of what the agent printed (markdown emphasis around it is fine). */
|
|
21
|
+
function parseResultState(record) {
|
|
22
|
+
const text = record.chunks
|
|
23
|
+
.filter((chunk) => chunk.stream !== 'system')
|
|
24
|
+
.map((chunk) => chunk.data)
|
|
25
|
+
.join('')
|
|
26
|
+
.replace(ANSI, '');
|
|
27
|
+
const matches = [...text.matchAll(/^[\s*`>_-]*RESULT:\s*(ready|needs-input)\b/gm)];
|
|
28
|
+
return matches.at(-1)?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
class JobManager {
|
|
31
|
+
jobs = new Map();
|
|
32
|
+
/** Emits `job` with a JobInfo on every status change of any job. */
|
|
33
|
+
events = new EventEmitter();
|
|
34
|
+
constructor() {
|
|
35
|
+
this.events.setMaxListeners(0);
|
|
36
|
+
this.pruneOldLogs();
|
|
37
|
+
}
|
|
38
|
+
list() {
|
|
39
|
+
return [...this.jobs.values()].map((record) => record.info).reverse();
|
|
40
|
+
}
|
|
41
|
+
get(id) {
|
|
42
|
+
return this.jobs.get(id);
|
|
43
|
+
}
|
|
44
|
+
start(spec) {
|
|
45
|
+
for (const record of this.jobs.values()) {
|
|
46
|
+
if (record.info.status !== 'running')
|
|
47
|
+
continue;
|
|
48
|
+
const clash = record.spec.locks.find((lock) => spec.locks.includes(lock));
|
|
49
|
+
if (clash) {
|
|
50
|
+
throw new JobConflictError(`Cannot start: "${record.info.command}" is already running (${clash}). Wait for it to finish or cancel it.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
54
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
55
|
+
const record = {
|
|
56
|
+
info: {
|
|
57
|
+
id,
|
|
58
|
+
kind: spec.kind,
|
|
59
|
+
profile: spec.profile,
|
|
60
|
+
env: spec.env,
|
|
61
|
+
command: spec.command,
|
|
62
|
+
status: 'running',
|
|
63
|
+
exitCode: null,
|
|
64
|
+
signal: null,
|
|
65
|
+
startedAt: new Date().toISOString(),
|
|
66
|
+
endedAt: null,
|
|
67
|
+
acceptsInput: spec.acceptsInput,
|
|
68
|
+
...(spec.baselineCases && { baselineCases: spec.baselineCases }),
|
|
69
|
+
...(spec.reportId && { reportId: spec.reportId }),
|
|
70
|
+
...(spec.ai && {
|
|
71
|
+
caseId: spec.ai.caseId,
|
|
72
|
+
aiCommand: spec.ai.command,
|
|
73
|
+
sessionId: spec.ai.sessionId,
|
|
74
|
+
resultState: null,
|
|
75
|
+
}),
|
|
76
|
+
...(spec.continuesJobId && { continuesJobId: spec.continuesJobId }),
|
|
77
|
+
},
|
|
78
|
+
spec,
|
|
79
|
+
chunks: [],
|
|
80
|
+
bufferedChars: 0,
|
|
81
|
+
nextSeq: 0,
|
|
82
|
+
emitter: new EventEmitter(),
|
|
83
|
+
child: null,
|
|
84
|
+
cancelRequested: false,
|
|
85
|
+
logStream: fs.createWriteStream(path.join(LOG_DIR, `${id}.log`)),
|
|
86
|
+
formatter: spec.stream ? new AiStreamFormatter(spec.stream) : null,
|
|
87
|
+
heartbeat: null,
|
|
88
|
+
};
|
|
89
|
+
record.emitter.setMaxListeners(0);
|
|
90
|
+
this.jobs.set(id, record);
|
|
91
|
+
this.push(record, 'system', `$ ${spec.command}\n`);
|
|
92
|
+
let child;
|
|
93
|
+
try {
|
|
94
|
+
child = spawn(spec.file, spec.args, {
|
|
95
|
+
cwd: ROOT,
|
|
96
|
+
detached: true,
|
|
97
|
+
stdio: [spec.acceptsInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
98
|
+
env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: undefined },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
this.finish(record, null, null, `Failed to start: ${error.message}`);
|
|
103
|
+
return record.info;
|
|
104
|
+
}
|
|
105
|
+
record.child = child;
|
|
106
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
107
|
+
const decoder = new StringDecoder('utf8');
|
|
108
|
+
const formatted = stream === 'stdout' ? record.formatter : null;
|
|
109
|
+
const emit = (text) => {
|
|
110
|
+
const out = formatted ? formatted.feed(text) : text;
|
|
111
|
+
if (out)
|
|
112
|
+
this.push(record, stream, out);
|
|
113
|
+
this.syncAwaiting(record);
|
|
114
|
+
};
|
|
115
|
+
child[stream]?.on('data', (buffer) => {
|
|
116
|
+
const text = decoder.write(buffer);
|
|
117
|
+
if (text)
|
|
118
|
+
emit(text);
|
|
119
|
+
});
|
|
120
|
+
child[stream]?.on('end', () => {
|
|
121
|
+
const rest = decoder.end();
|
|
122
|
+
if (rest)
|
|
123
|
+
emit(rest);
|
|
124
|
+
const tail = formatted?.end();
|
|
125
|
+
if (tail)
|
|
126
|
+
this.push(record, stream, tail);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (record.formatter) {
|
|
130
|
+
const formatter = record.formatter;
|
|
131
|
+
record.heartbeat = setInterval(() => {
|
|
132
|
+
const line = formatter.waiting(QUIET_MS);
|
|
133
|
+
if (line)
|
|
134
|
+
this.push(record, 'system', line);
|
|
135
|
+
}, HEARTBEAT_MS);
|
|
136
|
+
record.heartbeat.unref();
|
|
137
|
+
}
|
|
138
|
+
child.stdin?.on('error', () => { });
|
|
139
|
+
let finished = false;
|
|
140
|
+
child.on('error', (error) => {
|
|
141
|
+
if (finished)
|
|
142
|
+
return;
|
|
143
|
+
finished = true;
|
|
144
|
+
this.finish(record, null, null, `Failed to start: ${error.message}`);
|
|
145
|
+
});
|
|
146
|
+
child.on('close', (code, signal) => {
|
|
147
|
+
if (finished)
|
|
148
|
+
return;
|
|
149
|
+
finished = true;
|
|
150
|
+
this.finish(record, code, signal);
|
|
151
|
+
});
|
|
152
|
+
this.events.emit('job', record.info);
|
|
153
|
+
return record.info;
|
|
154
|
+
}
|
|
155
|
+
write(id, text) {
|
|
156
|
+
const record = this.jobs.get(id);
|
|
157
|
+
if (!record || record.info.status !== 'running' || !record.info.acceptsInput)
|
|
158
|
+
return false;
|
|
159
|
+
return record.child?.stdin?.write(text) ?? false;
|
|
160
|
+
}
|
|
161
|
+
cancel(id) {
|
|
162
|
+
const record = this.jobs.get(id);
|
|
163
|
+
const pid = record?.child?.pid;
|
|
164
|
+
if (!record || record.info.status !== 'running' || !pid)
|
|
165
|
+
return false;
|
|
166
|
+
record.cancelRequested = true;
|
|
167
|
+
this.signalGroup(pid, 'SIGTERM');
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
if (record.info.status === 'running')
|
|
170
|
+
this.signalGroup(pid, 'SIGKILL');
|
|
171
|
+
}, KILL_GRACE_MS).unref();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
/** Stops everything; called when the server shuts down. */
|
|
175
|
+
killAll() {
|
|
176
|
+
for (const record of this.jobs.values()) {
|
|
177
|
+
const pid = record.child?.pid;
|
|
178
|
+
if (record.info.status === 'running' && pid)
|
|
179
|
+
this.signalGroup(pid, 'SIGTERM');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
|
|
183
|
+
syncAwaiting(record) {
|
|
184
|
+
const { info } = record;
|
|
185
|
+
if (info.status !== 'running' || !record.formatter)
|
|
186
|
+
return;
|
|
187
|
+
const now = record.formatter.awaiting;
|
|
188
|
+
const before = info.awaitingUser ?? null;
|
|
189
|
+
if (!now && !before)
|
|
190
|
+
return;
|
|
191
|
+
if (now && before && before.since === new Date(now.since).toISOString())
|
|
192
|
+
return;
|
|
193
|
+
info.awaitingUser = now ? { message: now.message, since: new Date(now.since).toISOString() } : null;
|
|
194
|
+
this.events.emit('job', info);
|
|
195
|
+
}
|
|
196
|
+
signalGroup(pid, signal) {
|
|
197
|
+
try {
|
|
198
|
+
process.kill(-pid, signal);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Already gone.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
push(record, stream, data) {
|
|
205
|
+
const chunk = { seq: record.nextSeq++, stream, data };
|
|
206
|
+
record.chunks.push(chunk);
|
|
207
|
+
record.bufferedChars += data.length;
|
|
208
|
+
while (record.bufferedChars > MAX_BUFFERED_CHARS && record.chunks.length > 1) {
|
|
209
|
+
record.bufferedChars -= record.chunks.shift().data.length;
|
|
210
|
+
}
|
|
211
|
+
record.logStream.write(stream === 'stderr' ? `[stderr] ${data}` : data);
|
|
212
|
+
record.emitter.emit('chunk', chunk);
|
|
213
|
+
}
|
|
214
|
+
finish(record, code, signal, note) {
|
|
215
|
+
const { info } = record;
|
|
216
|
+
if (record.heartbeat)
|
|
217
|
+
clearInterval(record.heartbeat);
|
|
218
|
+
info.awaitingUser = null;
|
|
219
|
+
info.exitCode = code;
|
|
220
|
+
info.signal = signal;
|
|
221
|
+
info.endedAt = new Date().toISOString();
|
|
222
|
+
info.status = record.cancelRequested
|
|
223
|
+
? 'cancelled'
|
|
224
|
+
: code === 0
|
|
225
|
+
? 'succeeded'
|
|
226
|
+
: 'failed';
|
|
227
|
+
const summary = note ??
|
|
228
|
+
(info.status === 'cancelled'
|
|
229
|
+
? '[cancelled]'
|
|
230
|
+
: signal
|
|
231
|
+
? `[process killed by ${signal}]`
|
|
232
|
+
: `[process exited with code ${code}]`);
|
|
233
|
+
if (record.spec.ai && info.status === 'succeeded')
|
|
234
|
+
info.resultState = parseResultState(record);
|
|
235
|
+
this.push(record, 'system', `\n${summary}\n`);
|
|
236
|
+
record.logStream.end();
|
|
237
|
+
try {
|
|
238
|
+
record.spec.onFinish?.(info);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
console.error('onFinish failed:', error);
|
|
242
|
+
}
|
|
243
|
+
record.emitter.emit('status', info);
|
|
244
|
+
this.events.emit('job', info);
|
|
245
|
+
this.pruneFinished();
|
|
246
|
+
}
|
|
247
|
+
pruneFinished() {
|
|
248
|
+
const finished = [...this.jobs.values()].filter((record) => record.info.status !== 'running');
|
|
249
|
+
for (const record of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) {
|
|
250
|
+
this.jobs.delete(record.info.id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
pruneOldLogs() {
|
|
254
|
+
if (!fs.existsSync(LOG_DIR))
|
|
255
|
+
return;
|
|
256
|
+
for (const name of fs.readdirSync(LOG_DIR)) {
|
|
257
|
+
const file = path.join(LOG_DIR, name);
|
|
258
|
+
if (Date.now() - fs.statSync(file).mtimeMs > LOG_RETENTION_MS)
|
|
259
|
+
fs.rmSync(file, { force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export const jobs = new JobManager();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AddProfileRequest, 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;
|