@asrunjs/asrun 0.2.0 → 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.
- package/.claude/commands/explore-case.md +18 -15
- package/.claude/commands/gen-spec.md +16 -13
- package/.claude/commands/heal-spec.md +26 -23
- package/.claude/commands/make-case.md +11 -8
- package/.claude/skills/exploring-cases/SKILL.md +146 -132
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
- package/.claude/skills/exploring-cases/references/readiness.md +146 -137
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
- package/.claude/skills/generating-specs/SKILL.md +219 -207
- package/.claude/skills/generating-specs/references/conventions.md +148 -148
- package/.claude/skills/making-cases/SKILL.md +72 -64
- package/.claude/skills/making-cases/references/normalizing.md +39 -38
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
- package/AGENTS.md +159 -154
- package/CLAUDE.md +2 -1
- package/README.md +512 -410
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/cases.d.ts +2 -15
- package/dist/lib/cases.js +5 -78
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/fixtures.js +2 -2
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +6 -2
- package/dist/lib/node-cli.js +16 -6
- package/dist/lib/profiles.d.ts +25 -1
- package/dist/lib/profiles.js +54 -1
- package/dist/lib/run-filters.js +10 -2
- package/dist/lib/session.d.ts +13 -1
- package/dist/lib/session.js +73 -7
- package/dist/lib/targets.d.ts +5 -13
- package/dist/lib/targets.js +4 -29
- package/dist/lib/values.js +2 -2
- package/dist/scripts/build.js +2 -1
- package/dist/scripts/check-cases.js +74 -6
- package/dist/scripts/doctor.js +115 -4
- package/dist/scripts/explore.js +22 -1
- package/dist/scripts/init.d.ts +7 -1
- package/dist/scripts/init.js +61 -13
- package/dist/scripts/lint-specs.js +4 -1
- package/dist/scripts/login.js +30 -2
- package/dist/ui/server/actions.d.ts +4 -0
- package/dist/ui/server/actions.js +25 -12
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +34 -9
- package/dist/ui/server/ai-stream.js +283 -77
- package/dist/ui/server/app.js +74 -12
- package/dist/ui/server/jobs.d.ts +7 -1
- package/dist/ui/server/jobs.js +69 -15
- package/dist/ui/server/profiles.d.ts +20 -1
- package/dist/ui/server/profiles.js +214 -38
- package/dist/ui/shared/ai-commands.d.ts +16 -6
- package/dist/ui/shared/ai-commands.js +35 -12
- package/dist/ui/shared/types.d.ts +114 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -692
- package/package.json +12 -1
- package/templates/.claude/commands/explore-case.md +22 -21
- package/templates/.claude/commands/gen-spec.md +21 -20
- package/templates/.claude/commands/heal-spec.md +31 -30
- package/templates/.claude/commands/make-case.md +13 -12
- package/templates/.mcp.json +8 -8
- package/templates/AGENTS.md +64 -45
- package/templates/cases/_template.md +57 -63
- package/templates/opencode.jsonc +16 -11
- package/templates/profiles.yaml +42 -29
- package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
- package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
- package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
- package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
- package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
- package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
- package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
- package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
- package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
- package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +22 -16
- package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
- package/ui/dist/assets/index-I7uU93kk.js +0 -294
package/dist/ui/server/jobs.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { StringDecoder } from 'node:string_decoder';
|
|
7
7
|
import { ROOT } from '../../lib/paths.js';
|
|
8
|
+
import { spawnTarget } from '../../lib/command.js';
|
|
8
9
|
import { AiStreamFormatter } from './ai-stream.js';
|
|
9
10
|
const LOG_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
|
|
10
11
|
const MAX_BUFFERED_CHARS = 4_000_000;
|
|
@@ -17,16 +18,36 @@ const HEARTBEAT_MS = 15_000;
|
|
|
17
18
|
export class JobConflictError extends Error {
|
|
18
19
|
}
|
|
19
20
|
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
20
|
-
/** The last `RESULT: ready|needs-input` line
|
|
21
|
+
/** The last `RESULT: ready|needs-input` line the agent said (markdown emphasis around it is fine). */
|
|
21
22
|
function parseResultState(record) {
|
|
22
23
|
const text = record.chunks
|
|
23
|
-
|
|
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'))
|
|
24
26
|
.map((chunk) => chunk.data)
|
|
25
|
-
.join('')
|
|
27
|
+
.join('\n')
|
|
26
28
|
.replace(ANSI, '');
|
|
27
29
|
const matches = [...text.matchAll(/^[\s*`>_-]*RESULT:\s*(ready|needs-input)\b/gm)];
|
|
28
30
|
return matches.at(-1)?.[1] ?? null;
|
|
29
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
|
+
}
|
|
30
51
|
class JobManager {
|
|
31
52
|
jobs = new Map();
|
|
32
53
|
/** Emits `job` with a JobInfo on every status change of any job. */
|
|
@@ -71,6 +92,7 @@ class JobManager {
|
|
|
71
92
|
caseId: spec.ai.caseId,
|
|
72
93
|
aiCommand: spec.ai.command,
|
|
73
94
|
sessionId: spec.ai.sessionId,
|
|
95
|
+
...(spec.ai.model && { aiModel: spec.ai.model }),
|
|
74
96
|
resultState: null,
|
|
75
97
|
}),
|
|
76
98
|
...(spec.continuesJobId && { continuesJobId: spec.continuesJobId }),
|
|
@@ -89,13 +111,23 @@ class JobManager {
|
|
|
89
111
|
record.emitter.setMaxListeners(0);
|
|
90
112
|
this.jobs.set(id, record);
|
|
91
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
|
+
}
|
|
92
120
|
let child;
|
|
93
121
|
try {
|
|
94
|
-
child = spawn(
|
|
122
|
+
child = spawn(target.file, target.args, {
|
|
95
123
|
cwd: ROOT,
|
|
96
|
-
detached
|
|
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',
|
|
97
127
|
stdio: [spec.acceptsInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
98
128
|
env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: undefined },
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
...(target.throughShell && { windowsVerbatimArguments: true }),
|
|
99
131
|
});
|
|
100
132
|
}
|
|
101
133
|
catch (error) {
|
|
@@ -107,9 +139,10 @@ class JobManager {
|
|
|
107
139
|
const decoder = new StringDecoder('utf8');
|
|
108
140
|
const formatted = stream === 'stdout' ? record.formatter : null;
|
|
109
141
|
const emit = (text) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
if (formatted)
|
|
143
|
+
formatted.feed(text).forEach((part) => this.pushPart(record, part));
|
|
144
|
+
else
|
|
145
|
+
this.push(record, stream, text);
|
|
113
146
|
this.syncAwaiting(record);
|
|
114
147
|
};
|
|
115
148
|
child[stream]?.on('data', (buffer) => {
|
|
@@ -121,9 +154,7 @@ class JobManager {
|
|
|
121
154
|
const rest = decoder.end();
|
|
122
155
|
if (rest)
|
|
123
156
|
emit(rest);
|
|
124
|
-
|
|
125
|
-
if (tail)
|
|
126
|
-
this.push(record, stream, tail);
|
|
157
|
+
formatted?.end().forEach((part) => this.pushPart(record, part));
|
|
127
158
|
});
|
|
128
159
|
}
|
|
129
160
|
if (record.formatter) {
|
|
@@ -131,7 +162,7 @@ class JobManager {
|
|
|
131
162
|
record.heartbeat = setInterval(() => {
|
|
132
163
|
const line = formatter.waiting(QUIET_MS);
|
|
133
164
|
if (line)
|
|
134
|
-
this.
|
|
165
|
+
this.pushPart(record, line);
|
|
135
166
|
}, HEARTBEAT_MS);
|
|
136
167
|
record.heartbeat.unref();
|
|
137
168
|
}
|
|
@@ -194,6 +225,12 @@ class JobManager {
|
|
|
194
225
|
this.events.emit('job', info);
|
|
195
226
|
}
|
|
196
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
|
+
}
|
|
197
234
|
try {
|
|
198
235
|
process.kill(-pid, signal);
|
|
199
236
|
}
|
|
@@ -201,14 +238,31 @@ class JobManager {
|
|
|
201
238
|
// Already gone.
|
|
202
239
|
}
|
|
203
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
|
+
}
|
|
204
254
|
push(record, stream, data) {
|
|
205
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) {
|
|
206
260
|
record.chunks.push(chunk);
|
|
207
|
-
record.bufferedChars += data.length;
|
|
261
|
+
record.bufferedChars += chunk.data.length;
|
|
208
262
|
while (record.bufferedChars > MAX_BUFFERED_CHARS && record.chunks.length > 1) {
|
|
209
263
|
record.bufferedChars -= record.chunks.shift().data.length;
|
|
210
264
|
}
|
|
211
|
-
record.logStream.write(
|
|
265
|
+
record.logStream.write(fileText);
|
|
212
266
|
record.emitter.emit('chunk', chunk);
|
|
213
267
|
}
|
|
214
268
|
finish(record, code, signal, note) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AddProfileRequest, ProfileInfo, ProfilesResponse } from '../shared/types.js';
|
|
1
|
+
import type { AddProfileRequest, EditProfileRequest, EditProfileResult, ProfileInfo, ProfilesResponse } from '../shared/types.js';
|
|
2
2
|
export declare function listProfileRows(): ProfilesResponse;
|
|
3
3
|
/** Opens each saved session on the real site (read-only) and records whether it is still logged in. */
|
|
4
4
|
export declare function verifyProfiles(only?: {
|
|
@@ -14,3 +14,22 @@ export declare class ProfileError extends Error {
|
|
|
14
14
|
* existing entries stay as they are.
|
|
15
15
|
*/
|
|
16
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;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { chromium } from '@playwright/test';
|
|
3
3
|
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
4
|
-
import { listEnvs, listProfiles, loadProfiles, PROFILES_FILE } from '../../lib/profiles.js';
|
|
4
|
+
import { DEFAULT_SESSION_CHECK_PATH, DEFAULT_SESSION_CHECK, listEnvs, listProfiles, loadProfiles, PROFILES_FILE, validateSessionCheck } from '../../lib/profiles.js';
|
|
5
5
|
import { authFile } from '../../lib/paths.js';
|
|
6
6
|
import { checkSession } from '../../lib/session.js';
|
|
7
7
|
import { withScheme } from '../shared/site-address.js';
|
|
@@ -16,10 +16,13 @@ function savedAt(profileId) {
|
|
|
16
16
|
const toInfo = (profile) => {
|
|
17
17
|
const saved = savedAt(profile.id);
|
|
18
18
|
const verification = verifications.get(profile.id);
|
|
19
|
+
// A session file saved after the check (a new login from the CLI, another browser tab) makes the old
|
|
20
|
+
// result stale, so the row shows "unverified" until it is checked again.
|
|
21
|
+
const stale = Boolean(saved && verification && saved > verification.checkedAt);
|
|
19
22
|
let status;
|
|
20
23
|
if (!saved)
|
|
21
24
|
status = 'missing';
|
|
22
|
-
else if (!verification)
|
|
25
|
+
else if (!verification || stale)
|
|
23
26
|
status = 'unverified';
|
|
24
27
|
else
|
|
25
28
|
status = verification.status === 'missing' ? 'unverified' : verification.status;
|
|
@@ -30,6 +33,10 @@ const toInfo = (profile) => {
|
|
|
30
33
|
baseURL: profile.baseURL,
|
|
31
34
|
account: profile.account,
|
|
32
35
|
tags: profile.tags,
|
|
36
|
+
startPath: profile.startPath,
|
|
37
|
+
sessionCheckPath: profile.sessionCheckPath,
|
|
38
|
+
sessionCheck: profile.sessionCheck,
|
|
39
|
+
email: profile.email,
|
|
33
40
|
status,
|
|
34
41
|
verifying: verifying.has(profile.id),
|
|
35
42
|
savedAt: saved,
|
|
@@ -113,20 +120,14 @@ function parseSiteAddress(text, label) {
|
|
|
113
120
|
return url;
|
|
114
121
|
}
|
|
115
122
|
const normalTags = (tags) => [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
|
|
116
|
-
|
|
117
|
-
* Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
|
|
118
|
-
* existing entries stay as they are.
|
|
119
|
-
*/
|
|
120
|
-
export function addProfile(request, file = PROFILES_FILE) {
|
|
121
|
-
const id = request.id.trim();
|
|
123
|
+
function readFields(request) {
|
|
122
124
|
const name = request.name.trim();
|
|
123
125
|
const env = request.env.trim();
|
|
124
126
|
const account = request.account?.trim() || undefined;
|
|
127
|
+
const startPath = request.startPath?.trim() || undefined;
|
|
125
128
|
const sessionCheckPath = request.sessionCheckPath?.trim() || undefined;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
|
|
129
|
-
}
|
|
129
|
+
// Same rules as the loader, so the file the form writes is one `pnpm asrun` accepts.
|
|
130
|
+
const sessionCheck = validateSessionCheck('Session check', request.sessionCheck ?? null);
|
|
130
131
|
if (!name)
|
|
131
132
|
throw new ProfileError('Name is required.', 400);
|
|
132
133
|
if (!ENV_NAME.test(env)) {
|
|
@@ -136,9 +137,68 @@ export function addProfile(request, file = PROFILES_FILE) {
|
|
|
136
137
|
throw new ProfileError('email: true is not allowed. Tests never send email in production.', 400);
|
|
137
138
|
}
|
|
138
139
|
const baseURL = parseSiteAddress(request.baseURL, 'Base URL').origin;
|
|
140
|
+
if (startPath && (!startPath.startsWith('/') || startPath.startsWith('//') || /\s/.test(startPath))) {
|
|
141
|
+
throw new ProfileError('Start path must be empty (the site root) or a path on the site that starts with a single "/" and contains no spaces.', 400);
|
|
142
|
+
}
|
|
139
143
|
if (sessionCheckPath && (!sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath))) {
|
|
140
144
|
throw new ProfileError('Session check path must start with "/" and contain no spaces.', 400);
|
|
141
145
|
}
|
|
146
|
+
return {
|
|
147
|
+
name,
|
|
148
|
+
env,
|
|
149
|
+
baseURL,
|
|
150
|
+
account,
|
|
151
|
+
tags: normalTags(request.tags),
|
|
152
|
+
startPath,
|
|
153
|
+
sessionCheckPath,
|
|
154
|
+
sessionCheck,
|
|
155
|
+
email: request.email === true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** The `sessionCheck` keys that differ from the defaults, or null when the profile can rely on them. */
|
|
159
|
+
function sessionCheckEntry(check) {
|
|
160
|
+
const entry = {};
|
|
161
|
+
if (check.loggedOutUrl !== DEFAULT_SESSION_CHECK.loggedOutUrl)
|
|
162
|
+
entry.loggedOutUrl = check.loggedOutUrl;
|
|
163
|
+
if (check.loggedInSelector)
|
|
164
|
+
entry.loggedInSelector = check.loggedInSelector;
|
|
165
|
+
if (check.loggedOutSelector)
|
|
166
|
+
entry.loggedOutSelector = check.loggedOutSelector;
|
|
167
|
+
if (check.timeoutMs !== DEFAULT_SESSION_CHECK.timeoutMs)
|
|
168
|
+
entry.timeoutMs = check.timeoutMs;
|
|
169
|
+
return Object.keys(entry).length > 0 ? entry : null;
|
|
170
|
+
}
|
|
171
|
+
const sameSessionCheck = (a, b) => a.loggedOutUrl === b.loggedOutUrl &&
|
|
172
|
+
a.loggedInSelector === b.loggedInSelector &&
|
|
173
|
+
a.loggedOutSelector === b.loggedOutSelector &&
|
|
174
|
+
a.timeoutMs === b.timeoutMs;
|
|
175
|
+
/**
|
|
176
|
+
* Writes the edited YAML document and restores the old file when the result cannot be loaded, so a
|
|
177
|
+
* failed save never leaves a profiles.yaml behind that the test runner cannot read.
|
|
178
|
+
*/
|
|
179
|
+
function saveDocument(file, doc, original, action) {
|
|
180
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
181
|
+
fs.writeFileSync(temp, doc.toString());
|
|
182
|
+
fs.renameSync(temp, file);
|
|
183
|
+
try {
|
|
184
|
+
loadProfiles(file);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
fs.writeFileSync(file, original);
|
|
188
|
+
throw new ProfileError(`Could not ${action}: ${error.message}`, 400);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
|
|
192
|
+
/**
|
|
193
|
+
* Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
|
|
194
|
+
* existing entries stay as they are.
|
|
195
|
+
*/
|
|
196
|
+
export function addProfile(request, file = PROFILES_FILE) {
|
|
197
|
+
const id = request.id.trim();
|
|
198
|
+
const fields = readFields(request);
|
|
199
|
+
if (!PROFILE_ID.test(id)) {
|
|
200
|
+
throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
|
|
201
|
+
}
|
|
142
202
|
if (loadProfiles(file).some((profile) => profile.id === id)) {
|
|
143
203
|
throw new ProfileError(`Profile "${id}" already exists.`, 409);
|
|
144
204
|
}
|
|
@@ -153,43 +213,159 @@ export function addProfile(request, file = PROFILES_FILE) {
|
|
|
153
213
|
// `profiles: []` is a flow sequence; adding a map entry to it would render `[ { ... } ]`. Keep the file block-style.
|
|
154
214
|
profiles.flow = false;
|
|
155
215
|
const hadEntries = profiles.items.length > 0;
|
|
216
|
+
const check = sessionCheckEntry(fields.sessionCheck);
|
|
156
217
|
const entry = doc.createNode({
|
|
157
218
|
id,
|
|
158
|
-
name,
|
|
159
|
-
env,
|
|
160
|
-
baseURL,
|
|
161
|
-
...(account && { account }),
|
|
162
|
-
...(tags.length > 0 && { tags }),
|
|
163
|
-
...(
|
|
219
|
+
name: fields.name,
|
|
220
|
+
env: fields.env,
|
|
221
|
+
baseURL: fields.baseURL,
|
|
222
|
+
...(fields.account && { account: fields.account }),
|
|
223
|
+
...(fields.tags.length > 0 && { tags: fields.tags }),
|
|
224
|
+
...(fields.startPath && { startPath: fields.startPath }),
|
|
225
|
+
...(fields.sessionCheckPath && { sessionCheckPath: fields.sessionCheckPath }),
|
|
226
|
+
...(check && { sessionCheck: check }),
|
|
164
227
|
// Never send email from a new profile until a human has checked that a mailbox is connected.
|
|
165
|
-
email:
|
|
228
|
+
email: fields.email,
|
|
166
229
|
});
|
|
167
230
|
if (isMap(entry)) {
|
|
168
231
|
entry.spaceBefore = hadEntries;
|
|
169
232
|
entry.flow = false;
|
|
170
233
|
}
|
|
171
234
|
profiles.add(entry);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
235
|
+
saveDocument(file, doc, original, 'add the profile');
|
|
236
|
+
return toInfo(loadProfiles(file).find((profile) => profile.id === id));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Deletes the saved login of one profile, so it has to log in again. The file holds the session cookies
|
|
240
|
+
* and is never read here, only removed.
|
|
241
|
+
*/
|
|
242
|
+
export function forgetSession(id, file = PROFILES_FILE) {
|
|
243
|
+
const profile = loadProfiles(file).find((candidate) => candidate.id === id);
|
|
244
|
+
if (!profile)
|
|
245
|
+
throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
|
|
246
|
+
fs.rmSync(authFile(id), { force: true });
|
|
247
|
+
verifications.delete(id);
|
|
248
|
+
return toInfo(profile);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Removes one profile from profiles.yaml and deletes its saved login. Only that entry is written, so the
|
|
252
|
+
* comments and the other entries stay in the file. Screenshot baselines named after the id stay on disk:
|
|
253
|
+
* they are files of the cases, not of the profile.
|
|
254
|
+
*/
|
|
255
|
+
export function deleteProfile(id, file = PROFILES_FILE) {
|
|
256
|
+
const profile = loadProfiles(file).find((candidate) => candidate.id === id);
|
|
257
|
+
if (!profile)
|
|
258
|
+
throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
|
|
259
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
260
|
+
const doc = parseDocument(original, { merge: true });
|
|
261
|
+
if (doc.errors.length > 0) {
|
|
262
|
+
throw new ProfileError('profiles.yaml has syntax errors; fix it by hand first.', 400);
|
|
178
263
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
throw new ProfileError(
|
|
264
|
+
const profiles = doc.get('profiles');
|
|
265
|
+
if (!isSeq(profiles))
|
|
266
|
+
throw new ProfileError('profiles.yaml has no "profiles" list.', 400);
|
|
267
|
+
const index = profiles.items.findIndex((item) => isMap(item) && item.get('id') === id);
|
|
268
|
+
if (index < 0)
|
|
269
|
+
throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
|
|
270
|
+
profiles.delete(index);
|
|
271
|
+
// The entry that is first now keeps the blank line of the one before it; drop it so `profiles:` is followed
|
|
272
|
+
// directly by the entry, like a hand-written file.
|
|
273
|
+
const first = profiles.items[0];
|
|
274
|
+
if (isMap(first))
|
|
275
|
+
first.spaceBefore = false;
|
|
276
|
+
// A `profiles:` with nothing under it is null; the loader needs a list (it may be empty while the suite is
|
|
277
|
+
// set up), so an emptied list is written as `[]`.
|
|
278
|
+
if (profiles.items.length === 0)
|
|
279
|
+
profiles.flow = true;
|
|
280
|
+
saveDocument(file, doc, original, 'delete the profile');
|
|
281
|
+
// The saved login belonged to this profile. The file is never read here, only removed.
|
|
282
|
+
fs.rmSync(authFile(id), { force: true });
|
|
283
|
+
verifications.delete(id);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Changes the fields of one existing profile. Only the fields that actually changed are written, so
|
|
287
|
+
* comments on the others stay in the file. When the site address changes, the saved login belongs to
|
|
288
|
+
* the old site and is deleted: the profile has to log in again on the new one.
|
|
289
|
+
*/
|
|
290
|
+
export function editProfile(request, file = PROFILES_FILE) {
|
|
291
|
+
const id = request.id.trim();
|
|
292
|
+
if (!PROFILE_ID.test(id)) {
|
|
293
|
+
throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
|
|
294
|
+
}
|
|
295
|
+
const fields = readFields(request);
|
|
296
|
+
const before = loadProfiles(file).find((profile) => profile.id === id);
|
|
297
|
+
if (!before)
|
|
298
|
+
throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
|
|
299
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
300
|
+
const doc = parseDocument(original, { merge: true });
|
|
301
|
+
if (doc.errors.length > 0) {
|
|
302
|
+
throw new ProfileError('profiles.yaml has syntax errors; fix it by hand first.', 400);
|
|
303
|
+
}
|
|
304
|
+
const profiles = doc.get('profiles');
|
|
305
|
+
if (!isSeq(profiles))
|
|
306
|
+
throw new ProfileError('profiles.yaml has no "profiles" list.', 400);
|
|
307
|
+
const entry = profiles.items.find((item) => isMap(item) && item.get('id') === id);
|
|
308
|
+
if (!isMap(entry))
|
|
309
|
+
throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
|
|
310
|
+
if (fields.name !== before.name)
|
|
311
|
+
entry.set('name', fields.name);
|
|
312
|
+
if (fields.env !== before.env)
|
|
313
|
+
entry.set('env', fields.env);
|
|
314
|
+
if (fields.baseURL !== before.baseURL)
|
|
315
|
+
entry.set('baseURL', fields.baseURL);
|
|
316
|
+
if (fields.account) {
|
|
317
|
+
if (fields.account !== before.account)
|
|
318
|
+
entry.set('account', fields.account);
|
|
319
|
+
}
|
|
320
|
+
else if (entry.has('account')) {
|
|
321
|
+
entry.delete('account');
|
|
322
|
+
}
|
|
323
|
+
if (!sameList(fields.tags, before.tags)) {
|
|
324
|
+
if (fields.tags.length > 0)
|
|
325
|
+
entry.set('tags', fields.tags);
|
|
326
|
+
else
|
|
327
|
+
entry.delete('tags');
|
|
328
|
+
}
|
|
329
|
+
// An empty path means the site root; drop an explicit key instead of writing an empty one.
|
|
330
|
+
if (fields.startPath) {
|
|
331
|
+
if (fields.startPath !== before.startPath || !entry.has('startPath')) {
|
|
332
|
+
entry.set('startPath', fields.startPath);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (entry.has('startPath')) {
|
|
336
|
+
entry.delete('startPath');
|
|
337
|
+
}
|
|
338
|
+
// An omitted path means the default; drop an explicit key instead of writing the default out.
|
|
339
|
+
if (fields.sessionCheckPath && fields.sessionCheckPath !== DEFAULT_SESSION_CHECK_PATH) {
|
|
340
|
+
if (fields.sessionCheckPath !== before.sessionCheckPath || !entry.has('sessionCheckPath')) {
|
|
341
|
+
entry.set('sessionCheckPath', fields.sessionCheckPath);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else if (entry.has('sessionCheckPath')) {
|
|
345
|
+
entry.delete('sessionCheckPath');
|
|
346
|
+
}
|
|
347
|
+
// The same for the session check signals: write only what differs from the defaults, so the entry
|
|
348
|
+
// stays readable (and a comment above it stays in place when nothing about it changed).
|
|
349
|
+
if (!sameSessionCheck(fields.sessionCheck, before.sessionCheck)) {
|
|
350
|
+
const check = sessionCheckEntry(fields.sessionCheck);
|
|
351
|
+
if (check)
|
|
352
|
+
entry.set('sessionCheck', check);
|
|
353
|
+
else
|
|
354
|
+
entry.delete('sessionCheck');
|
|
355
|
+
}
|
|
356
|
+
if (fields.email !== before.email)
|
|
357
|
+
entry.set('email', fields.email);
|
|
358
|
+
saveDocument(file, doc, original, 'save the profile');
|
|
359
|
+
const siteChanged = fields.baseURL !== before.baseURL;
|
|
360
|
+
const hadSession = siteChanged && fs.existsSync(authFile(id));
|
|
361
|
+
if (siteChanged) {
|
|
362
|
+
// The saved login holds cookies of the old site. Deleting the file (never reading it) makes the
|
|
363
|
+
// profile log in again on the new address.
|
|
364
|
+
fs.rmSync(authFile(id), { force: true });
|
|
365
|
+
verifications.delete(id);
|
|
182
366
|
}
|
|
183
367
|
return {
|
|
184
|
-
id,
|
|
185
|
-
|
|
186
|
-
env,
|
|
187
|
-
baseURL,
|
|
188
|
-
account: account ?? null,
|
|
189
|
-
tags,
|
|
190
|
-
status: 'missing',
|
|
191
|
-
verifying: false,
|
|
192
|
-
savedAt: null,
|
|
193
|
-
checkedAt: null,
|
|
368
|
+
profile: toInfo(loadProfiles(file).find((profile) => profile.id === id)),
|
|
369
|
+
sessionCleared: hadSession,
|
|
194
370
|
};
|
|
195
371
|
}
|
|
@@ -21,10 +21,17 @@ export declare const AI_ENV_HINT: Record<AiCommand, string>;
|
|
|
21
21
|
export interface AiRunOptions {
|
|
22
22
|
/** The environment to explore. There is no default. */
|
|
23
23
|
env: string;
|
|
24
|
+
/**
|
|
25
|
+
* The model to run the agent with, as `provider/model` (`#variant` for OpenCode) or a Claude alias/name.
|
|
26
|
+
* Omitted = whatever the CLI would pick by itself, which for OpenCode is its built-in free model.
|
|
27
|
+
*/
|
|
28
|
+
model?: string;
|
|
24
29
|
/** `gen-spec` only: regenerate and overwrite an existing spec instead of leaving it alone. */
|
|
25
30
|
force?: boolean;
|
|
26
31
|
/** `explore-case` only: a person is at the machine, so steps that write data are handed to them in the browser. */
|
|
27
32
|
attended?: boolean;
|
|
33
|
+
/** Show the agent's reasoning in the log (`--thinking` for OpenCode). Off by default: it costs tokens and time. */
|
|
34
|
+
thinking?: boolean;
|
|
28
35
|
/** `heal-spec` only: the profiles to heal. Omitted = every profile that is logged in in `env`. */
|
|
29
36
|
profiles?: string[];
|
|
30
37
|
/** `heal-spec` only: file (relative to the project root) with the user's description and clues. */
|
|
@@ -53,26 +60,29 @@ export declare function headlessCommandLine(tool: AiTool, command: AiCommand, ca
|
|
|
53
60
|
/** Folder (relative to the AsRun root, git-ignored) where the clues typed into the Heal dialog are stored. */
|
|
54
61
|
export declare const HINTS_DIR = ".tmp/hints";
|
|
55
62
|
export declare const HINTS_MAX_CHARS = 8000;
|
|
63
|
+
/** Folder (relative to the AsRun root, git-ignored) where the answers typed into the Continue box are stored. */
|
|
64
|
+
export declare const REPLY_DIR = ".tmp/replies";
|
|
56
65
|
export declare const REPLY_MAX_CHARS = 4000;
|
|
57
66
|
export declare const USE_DEFAULTS_REPLY = "Accept the suggestion of every open question: delete its TODO block and keep the sentence above it as confirmed text.";
|
|
58
67
|
/**
|
|
59
68
|
* The next turn of a run that ended with `RESULT: needs-input`: the user's answers. They go into the case file, which
|
|
60
|
-
* stays the record of truth, and the task of the original command is finished.
|
|
69
|
+
* stays the record of truth, and the task of the original command is finished. The answers come from a file
|
|
70
|
+
* (`.tmp/replies/…md`) rather than from the prompt, so a line break or a quote in them cannot break the command line.
|
|
61
71
|
*/
|
|
62
|
-
export declare function replyPrompt(command: AiCommand,
|
|
72
|
+
export declare function replyPrompt(command: AiCommand, replyFile: string): string;
|
|
63
73
|
/** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
|
|
64
|
-
export declare function continueInvocation(tool: AiTool, command: AiCommand,
|
|
74
|
+
export declare function continueInvocation(tool: AiTool, command: AiCommand, replyFile: string, sessionId: string, model?: string, thinking?: boolean): {
|
|
65
75
|
file: string;
|
|
66
76
|
args: string[];
|
|
67
77
|
};
|
|
68
|
-
export declare const continueCommandLine: (tool: AiTool, command: AiCommand,
|
|
78
|
+
export declare const continueCommandLine: (tool: AiTool, command: AiCommand, replyFile: string, sessionId: string, model?: string, thinking?: boolean) => string;
|
|
69
79
|
/** Folder (relative to the AsRun root, git-ignored) where memos from the Cases page are stored. */
|
|
70
80
|
export declare const MEMO_DIR = ".tmp/memos";
|
|
71
81
|
export declare const MEMO_MAX_CHARS = 100000;
|
|
72
82
|
/** /make-case is interactive by design; a one-shot run cannot answer questions, so it must use the defaults. */
|
|
73
83
|
export declare const makeCasePrompt: (memoPath: string) => string;
|
|
74
|
-
export declare const makeCaseInvocation: (tool: AiTool, memoPath: string) => {
|
|
84
|
+
export declare const makeCaseInvocation: (tool: AiTool, memoPath: string, model?: string, thinking?: boolean) => {
|
|
75
85
|
file: string;
|
|
76
86
|
args: string[];
|
|
77
87
|
};
|
|
78
|
-
export declare const makeCaseCommandLine: (tool: AiTool, memoPath: string) => string;
|
|
88
|
+
export declare const makeCaseCommandLine: (tool: AiTool, memoPath: string, model?: string, thinking?: boolean) => string;
|