@asrunjs/asrun 0.7.0 → 0.9.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 +6 -2
- package/.claude/commands/gen-spec.md +4 -1
- package/.claude/commands/heal-spec.md +18 -7
- package/.claude/skills/exploring-cases/SKILL.md +19 -8
- package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
- package/.claude/skills/generating-specs/SKILL.md +85 -30
- package/AGENTS.md +23 -12
- package/README.md +25 -14
- package/bin/asrun.mjs +18 -2
- package/dist/lib/ffmpeg.js +21 -10
- package/dist/lib/migrations.d.ts +106 -0
- package/dist/lib/migrations.js +106 -0
- package/dist/lib/paths.d.ts +9 -0
- package/dist/lib/paths.js +12 -1
- package/dist/lib/registry.d.ts +31 -0
- package/dist/lib/registry.js +66 -0
- package/dist/lib/runtime-deps.d.ts +59 -0
- package/dist/lib/runtime-deps.js +161 -0
- package/dist/lib/tar.d.ts +5 -0
- package/dist/lib/tar.js +79 -0
- package/dist/lib/update-check.d.ts +52 -0
- package/dist/lib/update-check.js +223 -0
- package/dist/migrations/index.d.ts +23 -0
- package/dist/migrations/index.js +22 -0
- package/dist/migrations/manifest.json +1 -0
- package/dist/scripts/build.js +7 -0
- package/dist/scripts/cli.js +22 -4
- package/dist/scripts/doctor.js +51 -9
- package/dist/scripts/go.mjs +7 -5
- package/dist/scripts/init.d.ts +3 -2
- package/dist/scripts/init.js +73 -11
- package/dist/scripts/migrate.d.ts +1 -0
- package/dist/scripts/migrate.js +259 -0
- package/dist/scripts/run.js +2 -1
- package/dist/scripts/upgrade.d.ts +1 -0
- package/dist/scripts/upgrade.js +256 -0
- package/dist/ui/server/actions.js +9 -4
- package/dist/ui/server/app.js +18 -0
- package/dist/ui/server/index.js +3 -0
- package/dist/ui/server/update.d.ts +18 -0
- package/dist/ui/server/update.js +87 -0
- package/dist/ui/shared/ai-commands.d.ts +6 -3
- package/dist/ui/shared/ai-commands.js +11 -7
- package/dist/ui/shared/types.d.ts +25 -1
- package/docs/agents/framework-dev.md +74 -4
- package/docs/ai-workflow.md +38 -11
- package/docs/commands.md +74 -4
- package/docs/install.md +52 -16
- package/docs/theme-guide.html +93 -5
- package/docs/troubleshooting.md +22 -10
- package/docs/web-ui.md +28 -6
- package/package.json +2 -4
- package/templates/.claude/commands/explore-case.md +7 -2
- package/templates/.claude/commands/gen-spec.md +4 -1
- package/templates/.claude/commands/heal-spec.md +22 -12
- package/templates/AGENTS.md +1 -0
- package/templates/pnpm-workspace.yaml +3 -3
- package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-B7HeglDd.js} +1 -1
- package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-B9_GXR2G.js} +1 -1
- package/ui/dist/assets/{dist-CO4heSzh.js → dist-Bb36rNUw.js} +1 -1
- package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-DevASegc.js} +1 -1
- package/ui/dist/assets/{dist-C-eCSFja.js → dist-DfBpjgZT.js} +1 -1
- package/ui/dist/assets/{dist-O_RVi4tP.js → dist-Dq4Vnw5N.js} +1 -1
- package/ui/dist/assets/{dist-CPLhRzom.js → dist-Dqc_tfzC.js} +1 -1
- package/ui/dist/assets/{dist-LixzGniF.js → dist-G9KNeh6u.js} +1 -1
- package/ui/dist/assets/{dist-BudNAjOv.js → dist-GvRUDL-2.js} +1 -1
- package/ui/dist/assets/{dist-vmf3fnkM.js → dist-WvvCZeeu.js} +1 -1
- package/ui/dist/assets/index-BJ5D2mFK.js +346 -0
- package/ui/dist/assets/index-BXY-eKBn.css +2 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BIGt8Hfz.js +0 -338
- package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { spawnTarget } from '../lib/command.js';
|
|
6
|
+
import { compareVersions, isVersion, migrationState, pendingMigrations, readMarker, } from '../lib/migrations.js';
|
|
7
|
+
import { FRAMEWORK_ROOT, MARKER, ROOT } from '../lib/paths.js';
|
|
8
|
+
import { releaseInfo, releaseManifest } from '../lib/registry.js';
|
|
9
|
+
import { excludeReleaseAge } from '../lib/runtime-deps.js';
|
|
10
|
+
import { frameworkPath, PACKAGE } from './init.js';
|
|
11
|
+
/**
|
|
12
|
+
* `pnpm asrun upgrade`: one command that installs the newest framework, refreshes the workspace files and applies the
|
|
13
|
+
* migrations the new release brings.
|
|
14
|
+
*
|
|
15
|
+
* The order matters. The migration manifest of the *target* release is read out of its tarball first, so breaking
|
|
16
|
+
* changes are known before anything is written; the install comes next, and `init --update`, `migrate` and `doctor`
|
|
17
|
+
* then run through the *newly installed* package, never through the code that is being replaced.
|
|
18
|
+
*/
|
|
19
|
+
const USAGE = `Usage: pnpm asrun upgrade [--version <version|dist-tag>] [--root <dir>] [--dry-run] [--yes]
|
|
20
|
+
|
|
21
|
+
--version <v> the release to install (default: latest); a dist-tag or a concrete version
|
|
22
|
+
--root <dir> the workspace to upgrade (default: the current workspace)
|
|
23
|
+
--dry-run print the target release and the migrations it brings, and change nothing
|
|
24
|
+
--yes install without asking (a breaking change, or a release that cannot be checked)
|
|
25
|
+
|
|
26
|
+
What it does, in this order:
|
|
27
|
+
1. resolve the target release and read its migration manifest from the registry;
|
|
28
|
+
2. show the breaking changes and the migrations between the workspace's baseline and that release;
|
|
29
|
+
3. install @asrunjs/asrun at that version (a fresh release is exempted from pnpm's release age first);
|
|
30
|
+
4. run "init --update", "migrate" and "doctor" through the installed package.
|
|
31
|
+
|
|
32
|
+
A release older than the migration manifest cannot be checked in advance: the command says so and asks before it
|
|
33
|
+
installs. "pnpm asrun migrate" runs only the migration part, for a workspace that was upgraded by hand.`;
|
|
34
|
+
/** How a workspace reaches its install: the nearest `node_modules/@asrunjs/asrun` at or above the workspace root. */
|
|
35
|
+
function installedDir(root) {
|
|
36
|
+
for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
|
|
37
|
+
const candidate = path.join(dir, 'node_modules', PACKAGE);
|
|
38
|
+
if (fs.existsSync(path.join(candidate, 'package.json')))
|
|
39
|
+
return candidate;
|
|
40
|
+
if (path.dirname(dir) === dir)
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The version of the package installed in that workspace, or null when it is not there. */
|
|
45
|
+
function installedVersion(dir) {
|
|
46
|
+
if (dir === null)
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
50
|
+
return typeof manifest.version === 'string' ? manifest.version : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** A person types the word; without a terminal there is no confirmation, and the command refuses. */
|
|
57
|
+
async function confirm(question) {
|
|
58
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
59
|
+
return null;
|
|
60
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
try {
|
|
62
|
+
return (await rl.question(question)).trim().toLowerCase() === 'upgrade';
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
rl.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function printMigration(entry) {
|
|
69
|
+
const tags = [entry.breaking === true ? 'breaking' : '', entry.auto === true ? 'runs automatically' : 'needs a person']
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(', ');
|
|
72
|
+
console.log(` ${entry.breaking === true ? '!' : ' '} ${entry.version} ${entry.summary}`);
|
|
73
|
+
console.log(` ${entry.id}${tags === '' ? '' : ` (${tags})`}`);
|
|
74
|
+
if (entry.appliesTo !== undefined)
|
|
75
|
+
console.log(` affects: ${entry.appliesTo}`);
|
|
76
|
+
if (entry.details !== undefined)
|
|
77
|
+
console.log(` detail: ${entry.details}`);
|
|
78
|
+
for (const line of entry.manual ?? [])
|
|
79
|
+
console.log(` - ${line}`);
|
|
80
|
+
}
|
|
81
|
+
export async function main(args) {
|
|
82
|
+
if (args.includes('--help')) {
|
|
83
|
+
console.log(USAGE);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const value = (name) => {
|
|
87
|
+
const index = args.indexOf(name);
|
|
88
|
+
return index >= 0 && args[index + 1] !== undefined && !args[index + 1].startsWith('-') ? args[index + 1] : undefined;
|
|
89
|
+
};
|
|
90
|
+
const root = path.resolve(value('--root') ?? ROOT);
|
|
91
|
+
const dryRun = args.includes('--dry-run');
|
|
92
|
+
const yes = args.includes('--yes');
|
|
93
|
+
const spec = value('--version') ?? 'latest';
|
|
94
|
+
if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
|
|
95
|
+
console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun upgrade" in the project that uses ' +
|
|
96
|
+
'asrun, or pass --root <dir>.');
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
if (!fs.existsSync(path.join(root, MARKER))) {
|
|
100
|
+
console.error(`No ${MARKER} in ${root}: this is not an initialized asrun workspace. Run "pnpm asrun init" first.`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
console.log('[1/4] Checking the release');
|
|
104
|
+
const directory = installedDir(root);
|
|
105
|
+
const current = installedVersion(directory);
|
|
106
|
+
if (directory === null || current === null) {
|
|
107
|
+
// The framework's own checkout (examples/demo) has no installed package and must be updated with git; a project
|
|
108
|
+
// that never installed the framework has to add the dependency.
|
|
109
|
+
const checkout = !path.resolve(root, frameworkPath(root)).split(path.sep).includes('node_modules');
|
|
110
|
+
if (checkout) {
|
|
111
|
+
console.error(`${root} uses the framework's own checkout (${path.resolve(root, frameworkPath(root))}), not an installed ` +
|
|
112
|
+
'package: there is nothing to install. Update that checkout with git.');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.error(`${PACKAGE} is not installed in ${root}. Run "pnpm add -D ${PACKAGE}@${spec}" and then "pnpm asrun init".`);
|
|
116
|
+
}
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const lookup = releaseInfo(PACKAGE, spec);
|
|
120
|
+
if ('error' in lookup) {
|
|
121
|
+
console.error(`Could not read ${PACKAGE}@${spec} from the registry: ${lookup.error}`);
|
|
122
|
+
console.error(`\nUpgrade by hand instead:\n pnpm add -D ${PACKAGE}@${spec}\n pnpm asrun init --update\n pnpm asrun migrate\n` +
|
|
123
|
+
' pnpm asrun doctor');
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const target = lookup.info.version;
|
|
127
|
+
if (!isVersion(target) || !isVersion(current)) {
|
|
128
|
+
console.error(`The registry reported "${target}" and the workspace has "${current}"; expected versions.`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
const order = compareVersions(target, current);
|
|
132
|
+
if (order === 0) {
|
|
133
|
+
console.log(`${PACKAGE} ${current} is already installed in ${root}.`);
|
|
134
|
+
console.log('To refresh the workspace files or apply migrations that are still pending, run "pnpm asrun init --update" ' +
|
|
135
|
+
'and "pnpm asrun migrate".');
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
if (order < 0) {
|
|
139
|
+
console.error(`${PACKAGE}@${spec} is ${target}, which is older than the installed ${current}: upgrade never downgrades. ` +
|
|
140
|
+
`Run "pnpm add -D ${PACKAGE}@${target}" yourself if that is what you want.`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
// The registry serves the package as a tarball; the manifest in it is what makes the pre-install check possible.
|
|
144
|
+
const state = migrationState(readMarker(root), current);
|
|
145
|
+
let warning = null;
|
|
146
|
+
let pending = [];
|
|
147
|
+
try {
|
|
148
|
+
const manifest = await releaseManifest(lookup.info.tarball);
|
|
149
|
+
if (manifest === null) {
|
|
150
|
+
warning = `${PACKAGE}@${target} ships no migration manifest (it predates it), so nothing could be checked in advance.`;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
pending = pendingMigrations(manifest, state.since, target);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
warning = `the migration manifest of ${PACKAGE}@${target} could not be read (${error instanceof Error ? error.message : String(error)}), so nothing could be checked in advance.`;
|
|
158
|
+
}
|
|
159
|
+
console.log(`Workspace: ${root}`);
|
|
160
|
+
console.log(`asrun: ${current} -> ${target}`);
|
|
161
|
+
if (warning === null && pending.length === 0) {
|
|
162
|
+
console.log(`Migrations: none between ${state.since} and ${target}.`);
|
|
163
|
+
}
|
|
164
|
+
else if (pending.length > 0) {
|
|
165
|
+
console.log(`\nMigrations between ${state.since} and ${target}:`);
|
|
166
|
+
for (const entry of pending)
|
|
167
|
+
printMigration(entry);
|
|
168
|
+
}
|
|
169
|
+
if (warning !== null)
|
|
170
|
+
console.log(`\n ! Cannot check the workspace up front: ${warning}`);
|
|
171
|
+
const breaking = pending.filter((entry) => entry.breaking === true);
|
|
172
|
+
const needsConfirmation = breaking.length > 0 || warning !== null;
|
|
173
|
+
if (dryRun) {
|
|
174
|
+
console.log(`\nWould install ${PACKAGE}@${target}, then run "init --update", "migrate" and "doctor" ` +
|
|
175
|
+
`(${pending.filter((entry) => entry.auto === true).length} automatic migration(s)).`);
|
|
176
|
+
console.log('Nothing was installed (--dry-run).');
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
if (needsConfirmation) {
|
|
180
|
+
const question = breaking.length > 0
|
|
181
|
+
? `\nType "upgrade" to install ${PACKAGE}@${target} despite ${breaking.length} breaking change(s): `
|
|
182
|
+
: `\nType "upgrade" to install ${PACKAGE}@${target} without the pre-install check: `;
|
|
183
|
+
const answer = await confirm(question);
|
|
184
|
+
if (answer === null) {
|
|
185
|
+
console.error('Refusing to install: there is no terminal to confirm in. A person adds --yes to do it.');
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
if (!answer) {
|
|
189
|
+
console.error('Cancelled. Nothing was installed.');
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log(`\n[2/4] Installing ${PACKAGE}@${target}`);
|
|
194
|
+
const age = excludeReleaseAge(root, PACKAGE);
|
|
195
|
+
if (age === 'added' || age === 'created') {
|
|
196
|
+
console.log(` pnpm-workspace.yaml: ${PACKAGE} exempted from pnpm's release age`);
|
|
197
|
+
}
|
|
198
|
+
else if (age === 'inline') {
|
|
199
|
+
console.log(` ! pnpm-workspace.yaml has an inline "minimumReleaseAgeExclude": add ${PACKAGE} to it, or pnpm may install the ` +
|
|
200
|
+
'previous release instead of the one just published.');
|
|
201
|
+
}
|
|
202
|
+
const pnpm = spawnTarget('pnpm', ['add', '-D', `${PACKAGE}@${target}`]);
|
|
203
|
+
if (pnpm === null) {
|
|
204
|
+
console.error('pnpm was not found on PATH: install it, then run "pnpm asrun upgrade" again.');
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
const install = spawnSync(pnpm.file, pnpm.args, {
|
|
208
|
+
cwd: root,
|
|
209
|
+
stdio: 'inherit',
|
|
210
|
+
...(pnpm.throughShell && { windowsVerbatimArguments: true }),
|
|
211
|
+
});
|
|
212
|
+
if (install.status !== 0) {
|
|
213
|
+
const error = install.error;
|
|
214
|
+
const reason = error !== undefined
|
|
215
|
+
? error.code === 'ENOENT'
|
|
216
|
+
? 'pnpm was not found on PATH'
|
|
217
|
+
: error.message
|
|
218
|
+
: install.signal !== null
|
|
219
|
+
? `pnpm was stopped by ${install.signal}`
|
|
220
|
+
: `pnpm add exited with code ${install.status}`;
|
|
221
|
+
console.error(`\n ! ${PACKAGE}@${target} was not installed: ${reason}`);
|
|
222
|
+
console.error(` Nothing after this step ran. Fix the cause and run "pnpm asrun upgrade" again.`);
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
// Everything from here on runs the package that was just installed: its templates, its migrations, its checks. The
|
|
226
|
+
// process that started this command is still the old code, and must not write the marker with the old version.
|
|
227
|
+
const installedAfter = installedDir(root);
|
|
228
|
+
const after = installedVersion(installedAfter);
|
|
229
|
+
if (after !== null && after !== target) {
|
|
230
|
+
console.log(`\n ! pnpm installed ${PACKAGE}@${after}, not @${target} (pnpm's release age can hold a fresh release back). ` +
|
|
231
|
+
'The workspace is brought up to the version that is there; run "pnpm asrun upgrade" again later.');
|
|
232
|
+
}
|
|
233
|
+
const bin = installedAfter === null ? '' : path.join(installedAfter, 'bin', 'asrun.mjs');
|
|
234
|
+
if (bin === '' || !fs.existsSync(bin)) {
|
|
235
|
+
console.error(`\n ! The install finished, but the installed asrun command is missing (${bin || 'not found'}). ` +
|
|
236
|
+
'Run "pnpm install", then "pnpm asrun init --update".');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const env = { ...process.env, ASRUN_ROOT: root };
|
|
240
|
+
const run = (commandArgs) => spawnSync(process.execPath, [bin, ...commandArgs], { cwd: process.cwd(), stdio: 'inherit', env });
|
|
241
|
+
console.log(`\n[3/4] Refreshing the workspace files and applying migrations`);
|
|
242
|
+
const init = run(['init', '--root', root, '--update']);
|
|
243
|
+
if (init.status !== 0) {
|
|
244
|
+
console.error(`\n ! "init --update" failed. Fix the cause and run "pnpm asrun init --update", then "pnpm asrun migrate".`);
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
const migrate = run(['migrate', '--root', root, '--yes']);
|
|
248
|
+
if (migrate.status !== 0) {
|
|
249
|
+
console.error(`\n ! "migrate" failed. Fix the cause and run "pnpm asrun migrate" again.`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
console.log(`\n[4/4] Checking the workspace`);
|
|
253
|
+
const doctor = run(['doctor']);
|
|
254
|
+
console.log(`\nUpgraded ${PACKAGE} ${current} -> ${after ?? target} in ${root}.`);
|
|
255
|
+
return doctor.status ?? 0;
|
|
256
|
+
}
|
|
@@ -209,15 +209,20 @@ export function buildJobSpec(request) {
|
|
|
209
209
|
if (request.attended && request.command !== 'explore-case') {
|
|
210
210
|
throw new ActionError('Only explore-case can be attended.');
|
|
211
211
|
}
|
|
212
|
-
if ((request.
|
|
213
|
-
throw new ActionError('Only heal-spec takes
|
|
212
|
+
if ((request.hints !== undefined || request.report) && request.command !== 'heal-spec') {
|
|
213
|
+
throw new ActionError('Only heal-spec takes hints and a report.');
|
|
214
|
+
}
|
|
215
|
+
// The spec is generated for every profile the case names (and runs for every profile), so a selection could only
|
|
216
|
+
// narrow it wrongly.
|
|
217
|
+
if (request.profiles && request.command === 'gen-spec') {
|
|
218
|
+
throw new ActionError('gen-spec takes no profiles: it uses every profile the case holds information for.');
|
|
214
219
|
}
|
|
215
220
|
const profilesInEnv = listProfiles(request.env, knownProfiles).map((profile) => profile.id);
|
|
216
221
|
if (request.profiles?.some((id) => !profilesInEnv.includes(id))) {
|
|
217
222
|
throw new ActionError(`Unknown profile for "${request.env}".`);
|
|
218
223
|
}
|
|
219
224
|
if (request.profiles?.length === 0)
|
|
220
|
-
throw new ActionError('Choose at least one profile
|
|
225
|
+
throw new ActionError('Choose at least one profile.');
|
|
221
226
|
if (request.report !== undefined && !(REPORT_ID.test(request.report) && fs.existsSync(path.join(REPORTS_DIR, request.report, 'summary.json')))) {
|
|
222
227
|
throw new ActionError(`Unknown report "${request.report}".`);
|
|
223
228
|
}
|
|
@@ -234,7 +239,7 @@ export function buildJobSpec(request) {
|
|
|
234
239
|
thinking: request.thinking,
|
|
235
240
|
force: request.force,
|
|
236
241
|
attended: request.attended,
|
|
237
|
-
// Naming every profile is the same as leaving it out: the agent then takes all that are logged in.
|
|
242
|
+
// Naming every profile of the environment is the same as leaving it out: the agent then takes all that are logged in.
|
|
238
243
|
profiles: request.profiles && request.profiles.length < profilesInEnv.length ? request.profiles : undefined,
|
|
239
244
|
hints: hintsPath,
|
|
240
245
|
report: request.report,
|
package/dist/ui/server/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveInside, sendFile } from './files.js';
|
|
|
14
14
|
import { JobConflictError, jobs } from './jobs.js';
|
|
15
15
|
import { listAiModels } from './ai-models.js';
|
|
16
16
|
import { fileEvents } from './watch-files.js';
|
|
17
|
+
import { refreshUpdate, updateEvents, updateInfo } from './update.js';
|
|
17
18
|
const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
18
19
|
// Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
|
|
19
20
|
const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
|
|
@@ -228,6 +229,17 @@ export function createApp({ port }) {
|
|
|
228
229
|
const image = resolveInside(base, file.slice('.explore/'.length));
|
|
229
230
|
return image ? sendFile(c, image) : c.notFound();
|
|
230
231
|
});
|
|
232
|
+
// The release check of this server run. `refresh=1` asks the registry again and answers with the result (a person
|
|
233
|
+
// testing the notice, or a page that wants a fresh answer); otherwise the page gets the current state, and the first
|
|
234
|
+
// page load starts the check if it has not finished yet.
|
|
235
|
+
api.get('/update', async (c) => {
|
|
236
|
+
if (c.req.query('refresh') === '1')
|
|
237
|
+
return c.json(await refreshUpdate(true));
|
|
238
|
+
const info = updateInfo();
|
|
239
|
+
if (info.checkedAt === null)
|
|
240
|
+
void refreshUpdate(true);
|
|
241
|
+
return c.json(info);
|
|
242
|
+
});
|
|
231
243
|
api.get('/reports', (c) => {
|
|
232
244
|
if (!fs.existsSync(REPORTS_DIR))
|
|
233
245
|
return c.json([]);
|
|
@@ -288,12 +300,18 @@ export function createApp({ port }) {
|
|
|
288
300
|
if (open)
|
|
289
301
|
void stream.writeSSE({ event: 'files', data: JSON.stringify(change) }).catch(() => { });
|
|
290
302
|
};
|
|
303
|
+
const updateListener = (info) => {
|
|
304
|
+
if (open)
|
|
305
|
+
void stream.writeSSE({ event: 'update', data: JSON.stringify(info) }).catch(() => { });
|
|
306
|
+
};
|
|
291
307
|
jobs.events.on('job', listener);
|
|
292
308
|
fileEvents.on('files', filesListener);
|
|
309
|
+
updateEvents.on('update', updateListener);
|
|
293
310
|
stream.onAbort(() => {
|
|
294
311
|
open = false;
|
|
295
312
|
jobs.events.off('job', listener);
|
|
296
313
|
fileEvents.off('files', filesListener);
|
|
314
|
+
updateEvents.off('update', updateListener);
|
|
297
315
|
});
|
|
298
316
|
while (open) {
|
|
299
317
|
await stream.writeSSE({ event: 'ping', data: '' });
|
package/dist/ui/server/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createApp } from './app.js';
|
|
|
3
3
|
import { verifyProfiles } from './profiles.js';
|
|
4
4
|
import { jobs } from './jobs.js';
|
|
5
5
|
import { startFileWatcher } from './watch-files.js';
|
|
6
|
+
import { startUpdateWatcher } from './update.js';
|
|
6
7
|
const port = Number(process.env.ASRUN_UI_PORT ?? 4173);
|
|
7
8
|
const app = createApp({ port });
|
|
8
9
|
const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
|
|
@@ -15,6 +16,8 @@ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
|
|
|
15
16
|
console.log(`asrun UI: http://127.0.0.1:${port}`);
|
|
16
17
|
}
|
|
17
18
|
startFileWatcher();
|
|
19
|
+
// Once now (the notice lands on this terminal), then at every UTC midnight; the top bar picks the answer up.
|
|
20
|
+
startUpdateWatcher();
|
|
18
21
|
void verifyProfiles();
|
|
19
22
|
});
|
|
20
23
|
server.on('error', (error) => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { UpdateInfo } from '../shared/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The release check of the web UI: once when the server starts, then once a day at 00:00 UTC. The answer is cached in
|
|
5
|
+
* `.tmp/` (see lib/update-check.ts), so several tabs and the command line share one registry read. Pages learn about a
|
|
6
|
+
* change through the `update` event of `/api/events`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const updateEvents: EventEmitter<[never]>;
|
|
9
|
+
/** What the UI should show now; before the first answer every field that needs one is null. */
|
|
10
|
+
export declare function updateInfo(): UpdateInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Asks the registry again and tells the open pages. `force` goes past a fresh cache, which is what the startup and the
|
|
13
|
+
* daily checks do; the answer is written back to `.tmp/`, so the command line shares it. Overlapping calls share one
|
|
14
|
+
* read: the daily timer and the first page load can arrive together.
|
|
15
|
+
*/
|
|
16
|
+
export declare function refreshUpdate(force?: boolean): Promise<UpdateInfo>;
|
|
17
|
+
/** Today's check, then one at every UTC midnight while the server runs. */
|
|
18
|
+
export declare function startUpdateWatcher(): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { checkForUpdate, formatNotice, upgradeCommand, useColor } from '../../lib/update-check.js';
|
|
3
|
+
/**
|
|
4
|
+
* The release check of the web UI: once when the server starts, then once a day at 00:00 UTC. The answer is cached in
|
|
5
|
+
* `.tmp/` (see lib/update-check.ts), so several tabs and the command line share one registry read. Pages learn about a
|
|
6
|
+
* change through the `update` event of `/api/events`.
|
|
7
|
+
*/
|
|
8
|
+
export const updateEvents = new EventEmitter();
|
|
9
|
+
// One listener per open page; the default warning at ten tabs is noise.
|
|
10
|
+
updateEvents.setMaxListeners(0);
|
|
11
|
+
let status = null;
|
|
12
|
+
let inFlight = null;
|
|
13
|
+
/** The notice already printed, so a daily re-check of the same release does not repeat it. */
|
|
14
|
+
let printedNotice = null;
|
|
15
|
+
/** What the UI should show now; before the first answer every field that needs one is null. */
|
|
16
|
+
export function updateInfo() {
|
|
17
|
+
if (status === null) {
|
|
18
|
+
return {
|
|
19
|
+
current: null,
|
|
20
|
+
latest: null,
|
|
21
|
+
available: false,
|
|
22
|
+
checkedAt: null,
|
|
23
|
+
kind: null,
|
|
24
|
+
command: null,
|
|
25
|
+
disabled: false,
|
|
26
|
+
mocked: false,
|
|
27
|
+
error: null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
current: status.current,
|
|
32
|
+
latest: status.latest,
|
|
33
|
+
available: status.available,
|
|
34
|
+
checkedAt: status.checkedAt,
|
|
35
|
+
kind: status.kind,
|
|
36
|
+
command: status.available ? upgradeCommand(status.kind) : null,
|
|
37
|
+
disabled: status.disabled,
|
|
38
|
+
mocked: status.mocked,
|
|
39
|
+
error: status.error,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Asks the registry again and tells the open pages. `force` goes past a fresh cache, which is what the startup and the
|
|
44
|
+
* daily checks do; the answer is written back to `.tmp/`, so the command line shares it. Overlapping calls share one
|
|
45
|
+
* read: the daily timer and the first page load can arrive together.
|
|
46
|
+
*/
|
|
47
|
+
export function refreshUpdate(force = true) {
|
|
48
|
+
if (inFlight !== null)
|
|
49
|
+
return inFlight;
|
|
50
|
+
inFlight = (async () => {
|
|
51
|
+
try {
|
|
52
|
+
status = checkForUpdate({ force });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// checkForUpdate reports a failed read instead of throwing; if that ever changes, keep serving the last answer.
|
|
56
|
+
}
|
|
57
|
+
const answer = updateInfo();
|
|
58
|
+
const notice = status === null || status.disabled ? null : formatNotice(status, { color: useColor(process.stdout) });
|
|
59
|
+
// Printed on the terminal only when something changed: once at startup, and again for a newer release.
|
|
60
|
+
if (notice !== printedNotice && notice !== null) {
|
|
61
|
+
printedNotice = notice;
|
|
62
|
+
console.log(notice);
|
|
63
|
+
}
|
|
64
|
+
if (notice === null)
|
|
65
|
+
printedNotice = null;
|
|
66
|
+
updateEvents.emit('update', answer);
|
|
67
|
+
return answer;
|
|
68
|
+
})().finally(() => {
|
|
69
|
+
inFlight = null;
|
|
70
|
+
});
|
|
71
|
+
return inFlight;
|
|
72
|
+
}
|
|
73
|
+
/** Today's check, then one at every UTC midnight while the server runs. */
|
|
74
|
+
export function startUpdateWatcher() {
|
|
75
|
+
void refreshUpdate(true);
|
|
76
|
+
scheduleNextCheck();
|
|
77
|
+
}
|
|
78
|
+
function scheduleNextCheck() {
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const nextMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1);
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
void refreshUpdate(true);
|
|
83
|
+
scheduleNextCheck();
|
|
84
|
+
}, Math.max(1_000, nextMidnight - now.getTime()));
|
|
85
|
+
// The check must never keep the server process alive on its own.
|
|
86
|
+
timer.unref();
|
|
87
|
+
}
|
|
@@ -32,9 +32,12 @@ export interface AiRunOptions {
|
|
|
32
32
|
attended?: boolean;
|
|
33
33
|
/** Show the agent's reasoning in the log (`--thinking` for OpenCode). Off by default: it costs tokens and time. */
|
|
34
34
|
thinking?: boolean;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* `explore-case` and `heal-spec` only: the profiles to open / heal. Omitted = every profile that applies in `env`
|
|
37
|
+
* (all that are logged in, for heal). `gen-spec` takes none: it uses every profile the case names.
|
|
38
|
+
*/
|
|
36
39
|
profiles?: string[];
|
|
37
|
-
/** `heal-spec` only: file (relative to the project root) with the user
|
|
40
|
+
/** `heal-spec` only: file (relative to the project root) with what the user wants repaired or improved, and any clues. */
|
|
38
41
|
hints?: string;
|
|
39
42
|
/** `heal-spec` only: the report (folder name under reports/) to read first instead of the newest one. */
|
|
40
43
|
report?: string;
|
|
@@ -48,7 +51,7 @@ export interface AiSession {
|
|
|
48
51
|
id: string;
|
|
49
52
|
resume: boolean;
|
|
50
53
|
}
|
|
51
|
-
/** The prompt of a one-shot run.
|
|
54
|
+
/** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
|
|
52
55
|
export declare function headlessPrompt(command: AiCommand, caseId: string, options: AiRunOptions): string;
|
|
53
56
|
/** Executable and arguments of the one-shot (non-interactive) run. */
|
|
54
57
|
export declare function headlessInvocation(tool: AiTool, command: AiCommand, caseId: string, options: AiRunOptions, session?: AiSession): {
|
|
@@ -17,16 +17,18 @@ export const AI_COMMAND_WRITES = {
|
|
|
17
17
|
* Agents never write data in any of them: a person does those steps.
|
|
18
18
|
*/
|
|
19
19
|
export const AI_ENV_HINT = {
|
|
20
|
-
'explore-case': 'Where the agent explores.
|
|
20
|
+
'explore-case': 'Where the agent explores. Only the profiles you tick are opened; it only reads, and with Attended on you do the steps that write data.',
|
|
21
21
|
'gen-spec': 'Where the agent explores and verifies the spec.',
|
|
22
|
-
'heal-spec': 'Where the
|
|
22
|
+
'heal-spec': 'Where the failure is diagnosed and the pages are checked. Only the profiles that are logged in there are run.',
|
|
23
23
|
};
|
|
24
24
|
export const slashCommand = (command, caseId, options) => {
|
|
25
25
|
const { env, force, attended, profiles, hints, report } = options;
|
|
26
26
|
const heal = command === 'heal-spec';
|
|
27
|
+
// gen-spec has no profile option: its scope is every profile the case names.
|
|
28
|
+
const picksProfiles = command === 'explore-case' || heal;
|
|
27
29
|
return (`/${command} ${caseId} ${env}${force && command === 'gen-spec' ? ' --force' : ''}` +
|
|
28
30
|
`${attended && command === 'explore-case' ? ' --attended' : ''}` +
|
|
29
|
-
`${
|
|
31
|
+
`${picksProfiles && profiles?.length ? ` --profiles ${profiles.join(',')}` : ''}` +
|
|
30
32
|
`${heal && report ? ` --report ${report}` : ''}` +
|
|
31
33
|
`${heal && hints ? ` --hints ${hints}` : ''}`);
|
|
32
34
|
};
|
|
@@ -69,7 +71,9 @@ const STREAM_ARGS = {
|
|
|
69
71
|
opencode: ['--format', 'json'],
|
|
70
72
|
};
|
|
71
73
|
function invocation(tool, prompt, writes, session, model, thinking) {
|
|
72
|
-
// OpenCode creates the session when the id does not exist yet, and continues it when it does.
|
|
74
|
+
// OpenCode creates the session when the id does not exist yet, and continues it when it does. It also keeps the
|
|
75
|
+
// agent (build/plan) on the session, so a session started in plan mode would only propose changes and ask a question
|
|
76
|
+
// nobody can answer; every one-shot run is a build run, whatever the session it continues.
|
|
73
77
|
// A model is named on every run: without one OpenCode falls back to its built-in free model, which has a daily
|
|
74
78
|
// usage limit, and nothing in the CLI's own state (the TUI's last-used model) applies to a one-shot run.
|
|
75
79
|
if (tool === 'opencode')
|
|
@@ -78,6 +82,8 @@ function invocation(tool, prompt, writes, session, model, thinking) {
|
|
|
78
82
|
args: [
|
|
79
83
|
'run',
|
|
80
84
|
'--auto',
|
|
85
|
+
'--agent',
|
|
86
|
+
'build',
|
|
81
87
|
...STREAM_ARGS.opencode,
|
|
82
88
|
...(session ? ['-s', session.id] : []),
|
|
83
89
|
...(model ? ['--model', model] : []),
|
|
@@ -121,11 +127,9 @@ const ATTENDED = 'Attended run: a person is at the machine and the browser windo
|
|
|
121
127
|
'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
|
|
122
128
|
'there is no "Your turn" badge and no notification. Nobody can answer questions in the chat, so write open ' +
|
|
123
129
|
'questions into the case file as the skill describes and end with the RESULT: line.';
|
|
124
|
-
/** The prompt of a one-shot run.
|
|
130
|
+
/** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
|
|
125
131
|
export function headlessPrompt(command, caseId, options) {
|
|
126
132
|
const slash = slashCommand(command, caseId, options);
|
|
127
|
-
if (command === 'heal-spec')
|
|
128
|
-
return slash;
|
|
129
133
|
if (command === 'explore-case' && options.attended)
|
|
130
134
|
return `${slash} ${ATTENDED}`;
|
|
131
135
|
return `${slash} ${NON_INTERACTIVE}`;
|
|
@@ -103,7 +103,7 @@ export type StartJobRequest = {
|
|
|
103
103
|
* its built-in free model. `heal-spec` keeps the model of the run it continues.
|
|
104
104
|
*/
|
|
105
105
|
model?: string;
|
|
106
|
-
/** `heal-spec` only: the profiles to heal; omitted = every profile that
|
|
106
|
+
/** `explore-case` and `heal-spec` only: the profiles to open / heal; omitted = every profile that applies. `gen-spec` takes none: it uses every profile the case names. */
|
|
107
107
|
profiles?: string[];
|
|
108
108
|
/** `heal-spec` only: what the user knows (optional). The server stores it as a file for the agent. */
|
|
109
109
|
hints?: string;
|
|
@@ -332,6 +332,30 @@ export interface InfoResponse {
|
|
|
332
332
|
suites: string[];
|
|
333
333
|
caseIds: string[];
|
|
334
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* What the web UI shows about a newer release. The first check of a server run may still be running, so every field
|
|
337
|
+
* that needs an answer is null until it finished.
|
|
338
|
+
*/
|
|
339
|
+
export interface UpdateInfo {
|
|
340
|
+
/** The version of the framework this server runs; null until the first check finished. */
|
|
341
|
+
current: string | null;
|
|
342
|
+
/** The newest release on the registry; null when there is none (or the registry could not be read). */
|
|
343
|
+
latest: string | null;
|
|
344
|
+
/** True when `latest` is newer than `current`. */
|
|
345
|
+
available: boolean;
|
|
346
|
+
/** When the answer was fetched, ISO. A cached answer keeps the time of the fetch. */
|
|
347
|
+
checkedAt: string | null;
|
|
348
|
+
/** `installed` when the framework comes from the registry, `checkout` for the framework's own git repository. */
|
|
349
|
+
kind: 'installed' | 'checkout' | null;
|
|
350
|
+
/** The line to run to get the newest release; null when there is nothing to upgrade. */
|
|
351
|
+
command: string | null;
|
|
352
|
+
/** True when ASRUN_NO_UPDATE_CHECK turned the check off. */
|
|
353
|
+
disabled: boolean;
|
|
354
|
+
/** True when the answer is a development mock (ASRUN_UPDATE_MOCK_* or `.tmp/update-mock.json`). */
|
|
355
|
+
mocked: boolean;
|
|
356
|
+
/** Why the last check could not read the registry; the UI stays quiet then. */
|
|
357
|
+
error: string | null;
|
|
358
|
+
}
|
|
335
359
|
/** Everything that belongs to one case. Shown before a delete or rename, and returned by them. */
|
|
336
360
|
export interface CaseImpact {
|
|
337
361
|
id: string;
|