@asrunjs/asrun 0.9.0 → 0.11.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.
Files changed (78) hide show
  1. package/.claude/commands/explore-case.md +11 -6
  2. package/.claude/commands/gen-spec.md +10 -6
  3. package/.claude/commands/heal-spec.md +13 -9
  4. package/.claude/commands/make-case.md +1 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +84 -63
  6. package/.claude/skills/exploring-cases/references/assertion-targets.md +8 -4
  7. package/.claude/skills/exploring-cases/references/case-rules.md +110 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +73 -11
  9. package/.claude/skills/exploring-cases/references/notes.md +87 -0
  10. package/.claude/skills/exploring-cases/references/open-questions.md +58 -6
  11. package/.claude/skills/exploring-cases/references/readiness.md +7 -5
  12. package/.claude/skills/exploring-cases/references/tools.md +151 -0
  13. package/.claude/skills/exploring-cases/references/user-handoff.md +47 -18
  14. package/.claude/skills/generating-specs/SKILL.md +99 -54
  15. package/.claude/skills/generating-specs/references/conventions.md +4 -0
  16. package/.claude/skills/making-cases/SKILL.md +14 -7
  17. package/.claude/skills/making-cases/references/gaps.md +7 -4
  18. package/.claude/skills/making-cases/references/normalizing.md +1 -1
  19. package/AGENTS.md +61 -20
  20. package/README.md +10 -8
  21. package/bin/asrun.mjs +2 -0
  22. package/dist/lib/case-doc.d.ts +41 -4
  23. package/dist/lib/case-doc.js +109 -17
  24. package/dist/lib/explore-notes.d.ts +74 -0
  25. package/dist/lib/explore-notes.js +242 -0
  26. package/dist/lib/paths.d.ts +2 -0
  27. package/dist/lib/paths.js +2 -0
  28. package/dist/scripts/check-cases.js +89 -1
  29. package/dist/scripts/cli.js +9 -2
  30. package/dist/scripts/doctor.js +50 -3
  31. package/dist/scripts/explore-notes.d.ts +1 -0
  32. package/dist/scripts/explore-notes.js +131 -0
  33. package/dist/scripts/markers-confirm.d.ts +1 -0
  34. package/dist/scripts/markers-confirm.js +139 -0
  35. package/dist/ui/server/actions.js +50 -16
  36. package/dist/ui/server/app.js +7 -2
  37. package/dist/ui/server/job-store.d.ts +60 -0
  38. package/dist/ui/server/job-store.js +198 -0
  39. package/dist/ui/server/jobs.d.ts +46 -4
  40. package/dist/ui/server/jobs.js +361 -59
  41. package/dist/ui/server/process-info.d.ts +17 -0
  42. package/dist/ui/server/process-info.js +126 -0
  43. package/dist/ui/server/result-state.d.ts +10 -0
  44. package/dist/ui/server/result-state.js +106 -0
  45. package/dist/ui/shared/ai-commands.d.ts +4 -1
  46. package/dist/ui/shared/ai-commands.js +14 -4
  47. package/dist/ui/shared/types.d.ts +17 -2
  48. package/docs/agents/case-format.md +40 -5
  49. package/docs/agents/framework-dev.md +4 -0
  50. package/docs/ai-workflow.md +56 -16
  51. package/docs/cases.md +32 -8
  52. package/docs/commands.md +17 -1
  53. package/docs/theme-guide.html +56 -0
  54. package/docs/troubleshooting.md +40 -0
  55. package/docs/web-ui.md +59 -15
  56. package/package.json +3 -1
  57. package/templates/.claude/commands/explore-case.md +11 -6
  58. package/templates/.claude/commands/gen-spec.md +10 -5
  59. package/templates/.claude/commands/heal-spec.md +11 -7
  60. package/templates/.claude/commands/make-case.md +1 -1
  61. package/templates/AGENTS.md +9 -6
  62. package/templates/cases/_template.md +7 -0
  63. package/templates/package.json +2 -0
  64. package/ui/dist/assets/{dist-WvvCZeeu.js → dist--BXJInTq.js} +1 -1
  65. package/ui/dist/assets/{dist-B7HeglDd.js → dist-BMmNFIUH.js} +1 -1
  66. package/ui/dist/assets/{dist-DevASegc.js → dist-BXNaYHeh.js} +1 -1
  67. package/ui/dist/assets/{dist-Bb36rNUw.js → dist-ByLbEOlF.js} +1 -1
  68. package/ui/dist/assets/{dist-Dqc_tfzC.js → dist-C9ra6_BD.js} +1 -1
  69. package/ui/dist/assets/{dist-DfBpjgZT.js → dist-CK8mow8u.js} +1 -1
  70. package/ui/dist/assets/{dist-G9KNeh6u.js → dist-CQS0So-i.js} +1 -1
  71. package/ui/dist/assets/{dist-GvRUDL-2.js → dist-CTrwRc3Q.js} +1 -1
  72. package/ui/dist/assets/{dist-B9_GXR2G.js → dist-DTYj2vUD.js} +1 -1
  73. package/ui/dist/assets/{dist-Dq4Vnw5N.js → dist-mV0_nQZe.js} +1 -1
  74. package/ui/dist/assets/index-BzMqkmQ9.js +348 -0
  75. package/ui/dist/assets/index-DcJR-8Mt.css +2 -0
  76. package/ui/dist/index.html +2 -2
  77. package/ui/dist/assets/index-BJ5D2mFK.js +0 -346
  78. package/ui/dist/assets/index-BXY-eKBn.css +0 -2
@@ -111,7 +111,7 @@ export function createApp({ port }) {
111
111
  const assertProfileIdle = (id) => {
112
112
  const busy = jobs
113
113
  .list()
114
- .find((job) => job.status === 'running' &&
114
+ .find((job) => jobs.holdsLock(job) &&
115
115
  (job.kind === 'run' ||
116
116
  job.kind === 'ai' ||
117
117
  (job.profile === id && (job.kind === 'signin' || job.kind === 'visit'))));
@@ -157,7 +157,7 @@ export function createApp({ port }) {
157
157
  api.get('/case-impact/:id{.+}', (c) => c.json(caseImpact(c.req.param('id'))));
158
158
  // Moving or deleting files under a running agent or test run would break it.
159
159
  const assertNoRunningWork = () => {
160
- const busy = jobs.list().find((job) => job.status === 'running' && (job.kind === 'ai' || job.kind === 'run'));
160
+ const busy = jobs.list().find((job) => jobs.holdsLock(job) && (job.kind === 'ai' || job.kind === 'run'));
161
161
  if (busy)
162
162
  throw new CaseError(`Wait for "${busy.command}" to finish first.`, 409);
163
163
  };
@@ -280,6 +280,11 @@ export function createApp({ port }) {
280
280
  return c.json(await listAiModels(tool, c.req.query('refresh') === '1'));
281
281
  });
282
282
  api.get('/jobs', (c) => c.json(jobs.list()));
283
+ // What "Run again" starts. The stored request has no write-case or overwrite confirmation: the page asks again.
284
+ api.get('/jobs/:id/rerun', (c) => {
285
+ const request = jobs.rerunRequest(c.req.param('id'));
286
+ return request ? c.json({ request }) : c.json({ error: 'This job cannot be started again.' }, 404);
287
+ });
283
288
  api.post('/jobs', async (c) => {
284
289
  const request = startJobSchema.parse(await c.req.json());
285
290
  return c.json(jobs.start(buildJobSpec(request)), 201);
@@ -0,0 +1,60 @@
1
+ import type { AiCommand, AiTool } from '../shared/ai-commands.js';
2
+ import type { JobChunk, JobInfo, StartJobRequest } from '../shared/types.js';
3
+ /** What a job was started with, as far as it has to survive a restart. */
4
+ export interface PersistedJobSpec {
5
+ kind: JobInfo['kind'];
6
+ profile?: string;
7
+ env?: string;
8
+ command: string;
9
+ file: string;
10
+ args: string[];
11
+ locks: string[];
12
+ acceptsInput: boolean;
13
+ stream?: AiTool;
14
+ baselineCases?: string[];
15
+ reportId?: string;
16
+ ai?: {
17
+ tool: AiTool;
18
+ command: AiCommand;
19
+ caseId: string;
20
+ env: string;
21
+ sessionId: string;
22
+ model?: string;
23
+ thinking?: boolean;
24
+ };
25
+ continuesJobId?: string;
26
+ /** What "Run again" starts: the person's request, without the confirmations they gave. */
27
+ rerun?: StartJobRequest;
28
+ /** The file the command resolved to on PATH, kept so a process that outlived the server can be recognized. */
29
+ spawnedPath?: string;
30
+ }
31
+ /** One job on disk: what the UI shows, how to continue it, and which process to stop after a restart. */
32
+ export interface PersistedJob {
33
+ version: 1;
34
+ info: JobInfo;
35
+ spec: PersistedJobSpec;
36
+ /** The root process that was spawned; absent when it could not be started. */
37
+ pid?: number;
38
+ }
39
+ export declare const jobDir: (id: string) => string;
40
+ export declare const chunksFile: (id: string) => string;
41
+ export declare const logFile: (id: string) => string;
42
+ export declare function ensureJobDir(id: string): void;
43
+ /** Writes the record through a temporary file, so a crash cannot leave half a JSON document behind. */
44
+ export declare function writeRecord(record: PersistedJob): void;
45
+ /** Every record that can still be read, oldest first. A broken one is skipped: its log stays where it is. */
46
+ export declare function readJobs(): PersistedJob[];
47
+ /** The last chunks of a job, within the same memory budget the live job uses. */
48
+ export declare function readChunks(id: string, maxChars: number): {
49
+ chunks: JobChunk[];
50
+ nextSeq: number;
51
+ bufferedChars: number;
52
+ };
53
+ /** Adds a chunk to a job that is not running any more (a note about the restart). */
54
+ export declare function appendChunkNow(id: string, chunk: JobChunk, logText: string): void;
55
+ /**
56
+ * Deletes what is no longer worth keeping: older than the retention window, or beyond the newest `keep` finished jobs.
57
+ * Jobs still running in this server (`live`) and a leftover process that may still be alive are never deleted: that
58
+ * record is the only way to manage them. Flat `.log` files from before records existed are pruned by age.
59
+ */
60
+ export declare function pruneJobs(keep: number, maxAgeMs: number, live?: ReadonlySet<string>): void;
@@ -0,0 +1,198 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROOT } from '../../lib/paths.js';
4
+ const JOBS_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
5
+ const RECORD_FILE = 'record.json';
6
+ const CHUNKS_FILE = 'chunks.jsonl';
7
+ const LOG_FILE = 'output.log';
8
+ /** A temporary record file left behind by a crash is only useful while its writer may still be running. */
9
+ const STALE_TEMP_MS = 60 * 60 * 1000;
10
+ // Job ids are `crypto.randomBytes(6).toString('hex')`; the pattern also keeps a stray folder out of the paths below.
11
+ const JOB_ID = /^[a-f0-9]{8,32}$/;
12
+ /** How much of a chunks file is read back into memory; the full text stays in output.log. */
13
+ const MAX_TAIL_BYTES = 8 * 1024 * 1024;
14
+ export const jobDir = (id) => path.join(JOBS_DIR, id);
15
+ export const chunksFile = (id) => path.join(jobDir(id), CHUNKS_FILE);
16
+ export const logFile = (id) => path.join(jobDir(id), LOG_FILE);
17
+ export function ensureJobDir(id) {
18
+ fs.mkdirSync(jobDir(id), { recursive: true });
19
+ }
20
+ function readRecord(id) {
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(path.join(jobDir(id), RECORD_FILE), 'utf8'));
23
+ if (parsed?.version !== 1 || parsed.info?.id !== id)
24
+ return null;
25
+ return parsed;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Writes the record through a temporary file, so a crash cannot leave half a JSON document behind. */
32
+ export function writeRecord(record) {
33
+ const target = path.join(jobDir(record.info.id), RECORD_FILE);
34
+ ensureJobDir(record.info.id);
35
+ const temporary = `${target}.${process.pid}.tmp`;
36
+ try {
37
+ fs.writeFileSync(temporary, JSON.stringify(record));
38
+ fs.renameSync(temporary, target);
39
+ }
40
+ catch (error) {
41
+ fs.rmSync(temporary, { force: true });
42
+ throw error;
43
+ }
44
+ }
45
+ /** Every record that can still be read, oldest first. A broken one is skipped: its log stays where it is. */
46
+ export function readJobs() {
47
+ if (!fs.existsSync(JOBS_DIR))
48
+ return [];
49
+ const jobs = [];
50
+ for (const entry of fs.readdirSync(JOBS_DIR, { withFileTypes: true })) {
51
+ if (!entry.isDirectory() || !JOB_ID.test(entry.name))
52
+ continue;
53
+ const record = readRecord(entry.name);
54
+ if (record)
55
+ jobs.push(record);
56
+ else
57
+ console.warn(`Ignoring the job record in ${path.join(JOBS_DIR, entry.name)}: it could not be read.`);
58
+ }
59
+ return jobs.sort((left, right) => left.info.startedAt.localeCompare(right.info.startedAt));
60
+ }
61
+ /** The last chunks of a job, within the same memory budget the live job uses. */
62
+ export function readChunks(id, maxChars) {
63
+ let text = '';
64
+ try {
65
+ const file = chunksFile(id);
66
+ const size = fs.statSync(file).size;
67
+ const length = Math.min(size, Math.max(maxChars * 2, MAX_TAIL_BYTES));
68
+ const start = size - length;
69
+ if (length > 0) {
70
+ const buffer = Buffer.alloc(length);
71
+ const descriptor = fs.openSync(file, 'r');
72
+ try {
73
+ fs.readSync(descriptor, buffer, 0, length, start);
74
+ }
75
+ finally {
76
+ fs.closeSync(descriptor);
77
+ }
78
+ text = buffer.toString('utf8');
79
+ // The tail starts in the middle of a line: that chunk is not valid and is dropped below.
80
+ if (start > 0)
81
+ text = text.slice(text.indexOf('\n') + 1);
82
+ }
83
+ }
84
+ catch {
85
+ return { chunks: [], nextSeq: 0, bufferedChars: 0 };
86
+ }
87
+ const chunks = [];
88
+ let bufferedChars = 0;
89
+ for (const line of text.split('\n')) {
90
+ if (line.trim() === '')
91
+ continue;
92
+ try {
93
+ const chunk = JSON.parse(line);
94
+ if (typeof chunk?.seq !== 'number' || typeof chunk.data !== 'string')
95
+ continue;
96
+ chunks.push(chunk);
97
+ bufferedChars += chunk.data.length;
98
+ }
99
+ catch {
100
+ // A crash can leave the last line half-written; it is not a chunk.
101
+ }
102
+ }
103
+ while (bufferedChars > maxChars && chunks.length > 1) {
104
+ bufferedChars -= chunks.shift().data.length;
105
+ }
106
+ const nextSeq = chunks.reduce((next, chunk) => Math.max(next, chunk.seq + 1), 0);
107
+ return { chunks, nextSeq, bufferedChars };
108
+ }
109
+ /** Adds a chunk to a job that is not running any more (a note about the restart). */
110
+ export function appendChunkNow(id, chunk, logText) {
111
+ ensureJobDir(id);
112
+ fs.appendFileSync(chunksFile(id), `${JSON.stringify(chunk)}\n`);
113
+ fs.appendFileSync(logFile(id), logText);
114
+ }
115
+ /**
116
+ * Deletes what is no longer worth keeping: older than the retention window, or beyond the newest `keep` finished jobs.
117
+ * Jobs still running in this server (`live`) and a leftover process that may still be alive are never deleted: that
118
+ * record is the only way to manage them. Flat `.log` files from before records existed are pruned by age.
119
+ */
120
+ export function pruneJobs(keep, maxAgeMs, live = new Set()) {
121
+ if (!fs.existsSync(JOBS_DIR))
122
+ return;
123
+ const now = Date.now();
124
+ const records = [];
125
+ for (const entry of fs.readdirSync(JOBS_DIR, { withFileTypes: true })) {
126
+ const full = path.join(JOBS_DIR, entry.name);
127
+ if (!entry.isDirectory()) {
128
+ // The log of a job from before this format; no record, so only its age can decide.
129
+ if (/\.log$/i.test(entry.name)) {
130
+ try {
131
+ if (now - fs.statSync(full).mtimeMs > maxAgeMs)
132
+ fs.rmSync(full, { force: true });
133
+ }
134
+ catch {
135
+ // It disappeared while it was being looked at.
136
+ }
137
+ }
138
+ else if (/^record\.json\..+\.tmp$/i.test(entry.name)) {
139
+ try {
140
+ if (now - fs.statSync(full).mtimeMs > STALE_TEMP_MS)
141
+ fs.rmSync(full, { force: true });
142
+ }
143
+ catch {
144
+ // It disappeared while it was being looked at.
145
+ }
146
+ }
147
+ continue;
148
+ }
149
+ if (!JOB_ID.test(entry.name) || live.has(entry.name))
150
+ continue;
151
+ const record = readRecord(entry.name);
152
+ // A directory that never got its record (a crash between creating the folder and writing it) is dropped once it is
153
+ // old enough that no writer can still be filling it.
154
+ if (!record) {
155
+ try {
156
+ if (now - fs.statSync(full).mtimeMs > STALE_TEMP_MS)
157
+ fs.rmSync(full, { recursive: true, force: true });
158
+ }
159
+ catch {
160
+ // It disappeared while it was being looked at.
161
+ }
162
+ continue;
163
+ }
164
+ const leftover = record.info.leftover?.state;
165
+ const endedAt = record.info.endedAt ?? record.info.interruptedAt ?? record.info.startedAt;
166
+ const parsed = endedAt ? Date.parse(endedAt) : Number.NaN;
167
+ let time;
168
+ try {
169
+ time = Number.isFinite(parsed) ? parsed : fs.statSync(full).mtimeMs;
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ // A record from a crashed server has to survive until the next start can mark it; an orphan is pinned for ever,
175
+ // because it is the only way to stop its process.
176
+ const pinned = record.info.status === 'running' ||
177
+ (record.info.status === 'interrupted' && (leftover === 'alive' || leftover === 'unknown'));
178
+ records.push({ id: entry.name, time, pinned });
179
+ }
180
+ records.sort((left, right) => right.time - left.time);
181
+ let kept = 0;
182
+ for (const record of records) {
183
+ if (record.pinned)
184
+ continue;
185
+ const tooOld = now - record.time > maxAgeMs;
186
+ // `kept` counts the newest finished jobs, so the list is cleared from the back.
187
+ const tooMany = kept >= keep;
188
+ kept += 1;
189
+ if (!tooOld && !tooMany)
190
+ continue;
191
+ try {
192
+ fs.rmSync(jobDir(record.id), { recursive: true, force: true });
193
+ }
194
+ catch {
195
+ // A file still open in another process: leave it for the next cleanup.
196
+ }
197
+ }
198
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ChildProcess } from 'node:child_process';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import fs from 'node:fs';
4
- import type { JobChunk, JobInfo, JobKind } from '../shared/types.js';
4
+ import type { JobChunk, JobInfo, JobKind, StartJobRequest } from '../shared/types.js';
5
5
  import type { AiCommand, AiTool } from '../shared/ai-commands.js';
6
6
  import { AiStreamFormatter } from './ai-stream.js';
7
7
  export declare class JobConflictError extends Error {
@@ -36,6 +36,10 @@ export interface JobSpec {
36
36
  };
37
37
  /** The job whose conversation this one continues. */
38
38
  continuesJobId?: string;
39
+ /** What "Run again" starts: the request as the person made it, without the confirmations they gave. */
40
+ rerun?: StartJobRequest;
41
+ /** The file the command resolved to on PATH, used to recognize the process after a restart. */
42
+ spawnedPath?: string;
39
43
  /** Runs synchronously right before the final status is published. */
40
44
  onFinish?: (job: JobInfo) => void;
41
45
  }
@@ -47,34 +51,72 @@ interface JobRecord {
47
51
  nextSeq: number;
48
52
  emitter: EventEmitter;
49
53
  child: ChildProcess | null;
54
+ /** The root process, also kept on disk so a job that outlived the server can still be stopped. */
55
+ pid: number | null;
50
56
  cancelRequested: boolean;
51
- logStream: fs.WriteStream;
57
+ /** Set when the stall watchdog stopped the run: it ends as failed, not as a cancel the person asked for. */
58
+ stalled: boolean;
59
+ /** When the last block was added (a heartbeat line does not count, or it would keep the run alive). */
60
+ lastActivityAt: number;
61
+ /** Null for a job loaded from disk: it does not write any more. */
62
+ logStream: fs.WriteStream | null;
63
+ chunkStream: fs.WriteStream | null;
64
+ /** False for a finished job loaded from disk: its chunks are read only when someone opens it. */
65
+ chunksLoaded: boolean;
52
66
  formatter: AiStreamFormatter | null;
53
67
  heartbeat: NodeJS.Timeout | null;
54
68
  }
55
69
  declare class JobManager {
56
70
  private jobs;
71
+ private leftoverTimer;
57
72
  /** Emits `job` with a JobInfo on every status change of any job. */
58
73
  readonly events: EventEmitter<[never]>;
59
74
  constructor();
60
75
  list(): JobInfo[];
61
76
  get(id: string): JobRecord | undefined;
77
+ /** The request "Run again" starts, or undefined when this job was not started from one. */
78
+ rerunRequest(id: string): StartJobRequest | undefined;
79
+ /**
80
+ * True while this job still holds its locks. A job a restart left behind holds them only while its process is
81
+ * confirmed to be alive: starting another agent next to it would wedge the one MCP server for both.
82
+ */
83
+ holdsLock(info: JobInfo): boolean;
62
84
  start(spec: JobSpec): JobInfo;
63
85
  write(id: string, text: string): boolean;
64
86
  cancel(id: string): boolean;
65
87
  /** Stops everything; called when the server shuts down. */
66
88
  killAll(): void;
89
+ /**
90
+ * Reads the jobs of earlier server runs. One that was still running is marked `interrupted`: its output stops where
91
+ * the server died, and its process is either gone or a leftover the person can stop. An AI run that had already
92
+ * written its `RESULT:` line is recovered as succeeded, so its answers can still be answered.
93
+ */
94
+ private recover;
95
+ /** Reads a finished job's chunks the first time someone opens it; a job this server runs already has them. */
96
+ private ensureChunks;
97
+ private recoverInterrupted;
98
+ /** Keeps the leftover state current, so a process that exits by itself stops blocking new jobs. */
99
+ private startLeftoverProbe;
100
+ private checkLeftovers;
101
+ /** After a stop was requested, make sure the process is really gone; a signal that was ignored is not a stop. */
102
+ private verifyLeftoverStopped;
67
103
  /** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
68
104
  private syncAwaiting;
69
- private signalGroup;
105
+ /**
106
+ * Stops a run whose agent has said nothing for `STALL_MS`: a tool call that never returns leaves the CLI waiting for
107
+ * ever, and the log would only keep repeating "still waiting for ...". The block above the cancel names what to do.
108
+ */
109
+ private watchStall;
70
110
  /** A block of an AI run's log, with the time it was produced and how the UI should present it. */
71
111
  private pushPart;
72
112
  private push;
73
113
  /** Keeps the chunk for the browser and writes it to the job's log file, which is what is left after the run. */
74
114
  private store;
115
+ /** A chunk for a job that no longer runs: appended to both files, never to a stream that is still open. */
116
+ private pushRecovered;
117
+ private persist;
75
118
  private finish;
76
119
  private pruneFinished;
77
- private pruneOldLogs;
78
120
  }
79
121
  export declare const jobs: JobManager;
80
122
  export {};