@ravshansbox/pi-next-issue 2.21.1 → 3.0.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/index.ts CHANGED
@@ -1,102 +1,41 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
5
4
  import { Type } from 'typebox';
6
- import { must, type RunOptions } from './src/exec.ts';
5
+ import { answerAll, type Gh } from './src/answer.ts';
6
+ import { shared } from './src/clients.ts';
7
+ import { errorText } from './src/errors.ts';
8
+ import { must } from './src/exec.ts';
7
9
  import { repoRoot } from './src/git.ts';
10
+ import type { Progress, Question } from './src/protocol.ts';
11
+ import { startRun, statusOf, toggle } from './src/run.ts';
8
12
  import {
9
- type Progress,
10
- type Question,
11
- spawnWorker,
12
- WorkerClient,
13
- type WorkerEvent,
14
- workerPaths,
15
- } from './src/worker.ts';
13
+ cannotNote,
14
+ formatQuestions,
15
+ formatStatus,
16
+ handledNotes,
17
+ type Note,
18
+ questionNotes,
19
+ statusNote,
20
+ } from './src/status.ts';
21
+ import { spawnWorker, workerPaths } from './src/spawn-worker.ts';
22
+ import { WorkerClient, type WorkerEvent } from './src/worker.ts';
16
23
 
17
24
  export type Options = {
18
25
  client: (root: string) => WorkerClient;
19
26
  root: (cwd: string) => Promise<string>;
20
- gh: (args: string[], options: RunOptions) => Promise<string>;
27
+ gh: Gh;
21
28
  };
22
29
 
23
- const KEY = Symbol.for('pi-next-issue');
24
30
  const STATUS_KEY = 'next-issue';
25
31
  const QUESTIONS_KEY = 'next-issue-questions';
32
+ const RENDER_MS = 1000;
26
33
  const QUESTIONS_MS = 60_000;
27
- const RETRY = 'Retry';
28
- const GUIDE = 'Retry with guidance';
29
- const GIVE_UP = 'Give up';
30
34
 
31
- export function formatQuestions(count: number): string {
32
- return `${count} ${count === 1 ? 'question' : 'questions'} waiting`;
33
- }
34
-
35
- function errorText(error: unknown): string {
36
- return error instanceof Error ? error.message : String(error);
37
- }
38
-
39
- function shared(create: (root: string) => WorkerClient, root: string): WorkerClient {
40
- const store = globalThis as Record<symbol, Map<string, WorkerClient> | undefined>;
41
- const clients = (store[KEY] ??= new Map());
42
- let client = clients.get(root);
43
- if (client === undefined) {
44
- client = create(root);
45
- clients.set(root, client);
46
- }
47
- return client;
48
- }
49
-
50
- function duration(ms: number): string {
51
- const seconds = Math.max(0, Math.floor(ms / 1000));
52
- const minutes = Math.floor(seconds / 60);
53
- return minutes > 0 ? `${minutes}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
54
- }
55
-
56
- function outcomes(progress: Progress): string {
57
- const done = progress.handled.filter((item) => item.outcome === 'done').length;
58
- const human = progress.handled.filter((item) => item.outcome === 'needs-human').length;
59
- const failed = progress.handled.filter((item) => item.outcome === 'error').length;
60
- const parts = [`${done} done`];
61
- if (human > 0) {
62
- parts.push(`${human} needs a human`);
63
- }
64
- if (failed > 0) {
65
- parts.push(`${failed} failed`);
66
- }
67
- return parts.join(', ');
68
- }
69
-
70
- const OUTCOME_NOTES = { done: 'done', 'needs-human': 'needs a human', error: 'failed' } as const;
71
-
72
- export function formatStatus(progress: Progress, now: number): string {
73
- const running = progress.issues ?? [];
74
- switch (progress.status) {
75
- case 'running': {
76
- if (running.length === 0) {
77
- return 'looking for issues';
78
- }
79
- const count = progress.open === undefined ? '' : `/${progress.open}`;
80
- const lines = running.map((item) => {
81
- const parts = [`#${item.issue}${count}`, item.stage ?? 'prepare', `${item.tools} tools`];
82
- if (item.startedAt !== undefined) {
83
- parts.push(duration(now - item.startedAt));
84
- }
85
- return parts.join(' · ');
86
- });
87
- return lines.join(' | ');
88
- }
89
- case 'stopping': {
90
- const issues = running.map((item) => `#${item.issue}`);
91
- return `${issues.length === 0 ? '#?' : issues.join(' ')} · stopping`;
92
- }
93
- case 'stopped':
94
- return `stopped: ${outcomes(progress)}`;
95
- case 'finished':
96
- return `finished: ${outcomes(progress)}`;
97
- case 'failed':
98
- return `failed: ${progress.error ?? 'unknown error'}`;
99
- }
35
+ function background(callback: () => void, ms: number): ReturnType<typeof setInterval> {
36
+ const timer = setInterval(callback, ms);
37
+ timer.unref?.();
38
+ return timer;
100
39
  }
101
40
 
102
41
  function packageVersion(): string {
@@ -128,15 +67,23 @@ export function createExtension(options: Options) {
128
67
  let status: Progress['status'] | undefined;
129
68
  let baseline = false;
130
69
 
70
+ const clientOf = (root: string) => shared(options.client, root);
71
+
131
72
  const current = (): Progress | undefined =>
132
- root === undefined ? undefined : shared(options.client, root).runs[root];
73
+ root === undefined ? undefined : clientOf(root).runs[root];
74
+
75
+ const remember = (progress: Progress | undefined) => {
76
+ handled = progress?.handled.length ?? 0;
77
+ status = progress?.status;
78
+ };
79
+
80
+ const dim = (text: string) => (coloured && ui !== undefined ? ui.theme.fg('dim', text) : text);
133
81
 
134
82
  const render = () => {
135
83
  const progress = current();
136
84
  if (ui === undefined || progress === undefined) {
137
85
  return;
138
86
  }
139
- const dim = (text: string) => (coloured ? ui!.theme.fg('dim', text) : text);
140
87
  ui.setStatus(STATUS_KEY, dim(formatStatus(progress, Date.now())));
141
88
  };
142
89
 
@@ -144,71 +91,71 @@ export function createExtension(options: Options) {
144
91
  if (ui === undefined || waiting === undefined) {
145
92
  return;
146
93
  }
147
- const text = formatQuestions(waiting.length);
148
94
  ui.setStatus(
149
95
  QUESTIONS_KEY,
150
- waiting.length === 0 ? undefined : coloured ? ui.theme.fg('dim', text) : text,
96
+ waiting.length === 0 ? undefined : dim(formatQuestions(waiting.length)),
151
97
  );
152
98
  };
153
99
 
100
+ const tell = (note: Note | undefined) => {
101
+ if (note !== undefined) {
102
+ ui?.notify(note.text, note.level);
103
+ }
104
+ };
105
+
154
106
  const onQuestions = (questions: Question[]) => {
155
107
  const known = waiting;
156
108
  waiting = questions.map((question) => question.issue);
157
- for (const question of questions) {
158
- if (known !== undefined && !known.includes(question.issue)) {
159
- ui?.notify(`next-issue: #${question.issue} has a question: ${question.title}`, 'warning');
160
- }
161
- }
109
+ questionNotes(questions, known).forEach(tell);
162
110
  paintQuestions();
163
111
  };
164
112
 
165
113
  const askQuestions = () => {
166
114
  if (root !== undefined) {
167
- shared(options.client, root).send({ type: 'questions', root });
115
+ clientOf(root).send({ type: 'questions', root });
168
116
  }
169
117
  };
170
118
 
171
- const onProgress = (progress: Progress | undefined) => {
172
- if (progress === undefined) {
119
+ const announce = (progress: Progress) => {
120
+ if (progress.status === status) {
173
121
  return;
174
122
  }
123
+ status = progress.status;
124
+ tell(statusNote(progress));
125
+ };
126
+
127
+ const notifyHandled = (progress: Progress) => {
175
128
  if (progress.handled.length < handled) {
176
129
  handled = 0;
177
130
  }
178
- for (const item of progress.handled.slice(handled)) {
179
- ui?.notify(
180
- `next-issue: #${item.issue} ${OUTCOME_NOTES[item.outcome]}`,
181
- item.outcome === 'done' ? 'info' : 'warning',
182
- );
183
- }
131
+ handledNotes(progress, handled).forEach(tell);
184
132
  handled = progress.handled.length;
185
- if (progress.status !== status) {
186
- status = progress.status;
187
- if (status === 'finished') {
188
- ui?.notify('next-issue finished', 'info');
189
- } else if (status === 'stopped') {
190
- ui?.notify('next-issue stopped', 'info');
191
- } else if (status === 'failed') {
192
- ui?.notify(`next-issue failed: ${progress.error ?? 'unknown error'}`, 'error');
193
- }
133
+ };
134
+
135
+ const onProgress = (progress: Progress | undefined) => {
136
+ if (progress === undefined) {
137
+ return;
194
138
  }
139
+ notifyHandled(progress);
140
+ announce(progress);
195
141
  render();
196
142
  };
197
143
 
198
144
  const onEvent = (event: WorkerEvent) => {
199
- if (event.type === 'runs' && baseline) {
200
- baseline = false;
145
+ if (event.type === 'runs') {
201
146
  const progress = root === undefined ? undefined : event.runs[root];
202
- handled = progress?.handled.length ?? 0;
203
- status = progress?.status;
204
- render();
205
- askQuestions();
206
- } else if (event.type === 'runs') {
207
- onProgress(root === undefined ? undefined : event.runs[root]);
147
+ if (baseline) {
148
+ baseline = false;
149
+ remember(progress);
150
+ render();
151
+ askQuestions();
152
+ } else {
153
+ onProgress(progress);
154
+ }
208
155
  } else if (event.type === 'questions' && event.root === root) {
209
156
  onQuestions(event.questions);
210
157
  } else if (event.type === 'error' && event.root === root) {
211
- ui?.notify(`next-issue cannot start: ${event.message}`, 'error');
158
+ tell(cannotNote('start', event.message));
212
159
  }
213
160
  };
214
161
 
@@ -217,10 +164,8 @@ export function createExtension(options: Options) {
217
164
  if (root === undefined) {
218
165
  return;
219
166
  }
220
- const client = shared(options.client, root);
221
- const progress = current();
222
- handled = progress?.handled.length ?? 0;
223
- status = progress?.status;
167
+ const client = clientOf(root);
168
+ remember(current());
224
169
  waiting = client.questions[root]?.map((question) => question.issue);
225
170
  baseline = !client.connected;
226
171
  unsubscribe = client.subscribe(onEvent);
@@ -234,10 +179,8 @@ export function createExtension(options: Options) {
234
179
  const show = (ctx: ExtensionContext) => {
235
180
  ui = ctx.ui;
236
181
  coloured = ctx.mode === 'tui';
237
- timer ??= setInterval(render, 1000);
238
- timer.unref?.();
239
- poll ??= setInterval(askQuestions, QUESTIONS_MS);
240
- poll.unref?.();
182
+ timer ??= background(render, RENDER_MS);
183
+ poll ??= background(askQuestions, QUESTIONS_MS);
241
184
  };
242
185
 
243
186
  pi.on('session_start', async (_event, ctx) => {
@@ -250,20 +193,16 @@ export function createExtension(options: Options) {
250
193
  return;
251
194
  }
252
195
  follow();
253
- await shared(options.client, root).attach();
196
+ await clientOf(root).attach();
254
197
  });
255
198
 
256
199
  pi.on('session_shutdown', async () => {
257
200
  unsubscribe?.();
258
201
  unsubscribe = undefined;
259
- if (timer !== undefined) {
260
- clearInterval(timer);
261
- timer = undefined;
262
- }
263
- if (poll !== undefined) {
264
- clearInterval(poll);
265
- poll = undefined;
266
- }
202
+ clearInterval(timer);
203
+ timer = undefined;
204
+ clearInterval(poll);
205
+ poll = undefined;
267
206
  ui = undefined;
268
207
  });
269
208
 
@@ -273,42 +212,11 @@ export function createExtension(options: Options) {
273
212
  show(ctx);
274
213
  follow();
275
214
  }
276
- const client = shared(options.client, root);
215
+ const client = clientOf(root);
277
216
  await client.ensure();
278
217
  return { client, root, progress: client.runs[root] };
279
218
  };
280
219
 
281
- const going = (progress: Progress | undefined): progress is Progress =>
282
- progress?.status === 'running' || progress?.status === 'stopping';
283
-
284
- const accepted = (client: WorkerClient, root: string, signal: AbortSignal | undefined) =>
285
- new Promise<void>((resolve, reject) => {
286
- const finish = (error?: Error) => {
287
- stop();
288
- signal?.removeEventListener('abort', abort);
289
- if (error === undefined) {
290
- resolve();
291
- } else {
292
- reject(error);
293
- }
294
- };
295
- const abort = () => finish(new Error('next_issue_start was cancelled'));
296
- const stop = client.subscribe((event) => {
297
- if (event.type === 'error' && event.root === root) {
298
- finish(new Error(event.message));
299
- } else if (event.type === 'closed') {
300
- finish(new Error('the worker closed the connection'));
301
- } else if (event.type === 'runs' && going(event.runs[root])) {
302
- finish();
303
- }
304
- });
305
- if (signal?.aborted) {
306
- abort();
307
- } else {
308
- signal?.addEventListener('abort', abort, { once: true });
309
- }
310
- });
311
-
312
220
  pi.registerTool({
313
221
  name: 'next_issue_start',
314
222
  label: 'Start next-issue',
@@ -318,32 +226,10 @@ export function createExtension(options: Options) {
318
226
  parameters: Type.Object({}),
319
227
  async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
320
228
  const { client, root, progress } = await connect(ctx);
321
- if (going(progress)) {
322
- return {
323
- content: [
324
- {
325
- type: 'text',
326
- text: `A run of ${root} is going already, so nothing started: ${formatStatus(progress, Date.now())}. It picks up new issues by itself.`,
327
- },
328
- ],
329
- details: { started: false, root },
330
- };
331
- }
332
- const accepting = accepted(client, root, signal);
333
- client.send({ type: 'start', root, merge: true });
334
- await accepting;
335
- return {
336
- content: [{ type: 'text', text: `Started a next-issue run of ${root}.` }],
337
- details: { started: true, root },
338
- };
229
+ return startRun(client, root, progress, signal);
339
230
  },
340
231
  });
341
232
 
342
- const report = (text: string, root: string) => ({
343
- content: [{ type: 'text' as const, text }],
344
- details: { root },
345
- });
346
-
347
233
  pi.registerTool({
348
234
  name: 'next_issue_status',
349
235
  label: 'next-issue status',
@@ -353,120 +239,27 @@ export function createExtension(options: Options) {
353
239
  parameters: Type.Object({}),
354
240
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
355
241
  const root = await options.root(ctx.cwd);
356
- const client = shared(options.client, root);
357
- await client.attach();
358
- if (!client.connected) {
359
- return report('no worker is running', root);
360
- }
361
- const progress = client.runs[root];
362
- if (progress === undefined) {
363
- return report(`no run of ${root}`, root);
364
- }
365
- const lines = [
366
- formatStatus(progress, Date.now()),
367
- ...progress.handled.map((item) => `#${item.issue} ${OUTCOME_NOTES[item.outcome]}`),
368
- ];
369
- return report(lines.join('\n'), root);
242
+ return statusOf(clientOf(root), root);
370
243
  },
371
244
  });
372
245
 
373
- const fresh = (client: WorkerClient, root: string) =>
374
- new Promise<Question[]>((resolve, reject) => {
375
- const id = randomUUID();
376
- const stop = client.subscribe((event) => {
377
- if (event.type === 'questions' && event.root === root && event.id === id) {
378
- stop();
379
- resolve(event.questions);
380
- } else if (event.type === 'questions-error' && event.root === root && event.id === id) {
381
- stop();
382
- reject(new Error(event.message));
383
- } else if (event.type === 'closed') {
384
- stop();
385
- reject(new Error('the worker closed the connection'));
386
- }
387
- });
388
- client.send({ type: 'questions', root, id });
389
- });
390
-
391
- const reply = async (root: string, question: Question, choice: string, guidance: string) => {
392
- const threads: Array<[string, number]> = [['issue', question.issue]];
393
- if (question.pr !== undefined) {
394
- threads.push(['pr', question.pr]);
395
- }
396
- const repo = ['--repo', question.repo];
397
- if (choice === GUIDE) {
398
- const [kind, number] = threads.at(-1)!;
399
- await options.gh([kind, 'comment', String(number), ...repo, '--body-file', '-'], {
400
- cwd: root,
401
- input: guidance,
402
- });
403
- }
404
- const labels = ['--remove-label', question.labels.needsHuman];
405
- if (choice === GIVE_UP) {
406
- labels.push('--add-label', question.labels.skip);
407
- }
408
- for (const [kind, number] of threads) {
409
- await options.gh([kind, 'edit', String(number), ...repo, ...labels], { cwd: root });
410
- }
411
- };
412
-
413
- const answer = async (ctx: ExtensionContext) => {
414
- const { client, root } = await connect(ctx);
415
- const passed = new Set<number>();
416
- for (;;) {
417
- const question = (await fresh(client, root)).find((item) => !passed.has(item.issue));
418
- if (question === undefined) {
419
- if (passed.size === 0) {
420
- ctx.ui.notify('next-issue: no questions waiting', 'info');
421
- }
422
- return;
423
- }
424
- passed.add(question.issue);
425
- const choice = await ctx.ui.select(
426
- `#${question.issue} ${question.title}\n\n${question.reason || 'No reason given.'}`,
427
- [RETRY, GUIDE, GIVE_UP],
428
- );
429
- if (choice === undefined) {
430
- return;
431
- }
432
- let guidance = '';
433
- if (choice === GUIDE) {
434
- guidance = (await ctx.ui.editor(`Guidance for #${question.issue}`))?.trim() ?? '';
435
- if (guidance === '') {
436
- return;
437
- }
438
- }
439
- await reply(root, question, choice, guidance);
440
- ctx.ui.notify(
441
- `next-issue: #${question.issue} ${choice === GIVE_UP ? 'given up' : 'will be retried'}`,
442
- 'info',
443
- );
444
- }
445
- };
446
-
447
246
  pi.registerCommand('next-issue', {
448
247
  description:
449
248
  'Start or stop working through the GitHub issues of this repository, or answer the waiting questions with "answer"',
450
249
  getArgumentCompletions: (prefix) =>
451
250
  'answer'.startsWith(prefix) ? [{ value: 'answer', label: 'answer' }] : null,
452
251
  handler: async (args, ctx) => {
453
- if (args.trim() === 'answer') {
454
- try {
455
- await answer(ctx);
456
- } catch (error) {
457
- ctx.ui.notify(`next-issue cannot answer: ${errorText(error)}`, 'error');
458
- }
459
- return;
460
- }
252
+ const answering = args.trim() === 'answer';
461
253
  try {
462
254
  const { client, root, progress } = await connect(ctx);
463
- if (going(progress)) {
464
- client.send({ type: 'stop', root });
255
+ if (answering) {
256
+ await answerAll(ctx.ui, options.gh, client, root);
465
257
  } else {
466
- client.send({ type: 'start', root, merge: true });
258
+ toggle(client, root, progress);
467
259
  }
468
260
  } catch (error) {
469
- ctx.ui.notify(`next-issue cannot start: ${errorText(error)}`, 'error');
261
+ const note = cannotNote(answering ? 'answer' : 'start', errorText(error));
262
+ ctx.ui.notify(note.text, note.level);
470
263
  }
471
264
  },
472
265
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-next-issue",
3
- "version": "2.21.1",
3
+ "version": "3.0.0",
4
4
  "description": "Pi extension that works through GitHub issues with the next-issue worker",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/answer.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type { RunOptions } from './exec.ts';
3
+ import type { Question } from './protocol.ts';
4
+ import { fresh } from './requests.ts';
5
+ import type { WorkerClient } from './worker.ts';
6
+
7
+ export type Gh = (args: string[], options: RunOptions) => Promise<string>;
8
+
9
+ const RETRY = 'Retry';
10
+ const GUIDE = 'Retry with guidance';
11
+ const GIVE_UP = 'Give up';
12
+
13
+ type Answer = { choice: string; guidance: string };
14
+
15
+ function threadsOf(question: Question): Array<[string, number]> {
16
+ const issue: [string, number] = ['issue', question.issue];
17
+ return question.pr === undefined ? [issue] : [issue, ['pr', question.pr]];
18
+ }
19
+
20
+ function promptOf(question: Question): string {
21
+ return `#${question.issue} ${question.title}\n\n${question.reason || 'No reason given.'}`;
22
+ }
23
+
24
+ function answeredOf(question: Question, choice: string): string {
25
+ return `next-issue: #${question.issue} ${choice === GIVE_UP ? 'given up' : 'will be retried'}`;
26
+ }
27
+
28
+ function labelsOf(question: Question, choice: string): string[] {
29
+ const remove = ['--remove-label', question.labels.needsHuman];
30
+ return choice === GIVE_UP ? [...remove, '--add-label', question.labels.skip] : remove;
31
+ }
32
+
33
+ async function reply(
34
+ gh: Gh,
35
+ root: string,
36
+ question: Question,
37
+ { choice, guidance }: Answer,
38
+ ): Promise<void> {
39
+ const threads = threadsOf(question);
40
+ const repo = ['--repo', question.repo];
41
+ if (choice === GUIDE) {
42
+ const [kind, number] = threads.at(-1)!;
43
+ await gh([kind, 'comment', String(number), ...repo, '--body-file', '-'], {
44
+ cwd: root,
45
+ input: guidance,
46
+ });
47
+ }
48
+ const labels = labelsOf(question, choice);
49
+ for (const [kind, number] of threads) {
50
+ await gh([kind, 'edit', String(number), ...repo, ...labels], { cwd: root });
51
+ }
52
+ }
53
+
54
+ async function ask(ui: ExtensionContext['ui'], question: Question): Promise<Answer | undefined> {
55
+ const choice = await ui.select(promptOf(question), [RETRY, GUIDE, GIVE_UP]);
56
+ if (choice === undefined) {
57
+ return undefined;
58
+ }
59
+ if (choice !== GUIDE) {
60
+ return { choice, guidance: '' };
61
+ }
62
+ const guidance = (await ui.editor(`Guidance for #${question.issue}`))?.trim() ?? '';
63
+ return guidance === '' ? undefined : { choice, guidance };
64
+ }
65
+
66
+ export async function answerAll(
67
+ ui: ExtensionContext['ui'],
68
+ gh: Gh,
69
+ client: WorkerClient,
70
+ root: string,
71
+ ): Promise<void> {
72
+ const passed = new Set<number>();
73
+ for (;;) {
74
+ const question = (await fresh(client, root)).find((item) => !passed.has(item.issue));
75
+ if (question === undefined) {
76
+ if (passed.size === 0) {
77
+ ui.notify('next-issue: no questions waiting', 'info');
78
+ }
79
+ return;
80
+ }
81
+ passed.add(question.issue);
82
+ const answer = await ask(ui, question);
83
+ if (answer === undefined) {
84
+ return;
85
+ }
86
+ await reply(gh, root, question, answer);
87
+ ui.notify(answeredOf(question, answer.choice), 'info');
88
+ }
89
+ }
package/src/clients.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { WorkerClient } from './worker.ts';
2
+
3
+ const KEY = Symbol.for('pi-next-issue');
4
+
5
+ export function shared(create: (root: string) => WorkerClient, root: string): WorkerClient {
6
+ const store = globalThis as Record<symbol, Map<string, WorkerClient> | undefined>;
7
+ const clients = (store[KEY] ??= new Map());
8
+ let client = clients.get(root);
9
+ if (client === undefined) {
10
+ client = create(root);
11
+ clients.set(root, client);
12
+ }
13
+ return client;
14
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function errorText(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
package/src/exec.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
 
3
- export type Result = { code: number; stdout: string; stderr: string };
3
+ type Result = { code: number; stdout: string; stderr: string };
4
4
 
5
- export type RunOptions = { cwd: string; input?: string; signal?: AbortSignal };
5
+ export type RunOptions = { cwd: string; input?: string };
6
6
 
7
- export function run(command: string, args: string[], options: RunOptions): Promise<Result> {
7
+ function run(command: string, args: string[], options: RunOptions): Promise<Result> {
8
8
  return new Promise((resolve, reject) => {
9
9
  const child = spawn(command, args, {
10
10
  cwd: options.cwd,
11
11
  stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
12
- ...(options.signal === undefined ? {} : { signal: options.signal }),
13
12
  });
14
13
  let stdout = '';
15
14
  let stderr = '';
@@ -0,0 +1,93 @@
1
+ export type RunStatus = 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
2
+
3
+ export type Handled = { issue: number; outcome: 'done' | 'needs-human' | 'error' };
4
+
5
+ export type IssueProgress = {
6
+ issue: number;
7
+ title?: string;
8
+ stage?: string;
9
+ tools: number;
10
+ lastTool?: string;
11
+ startedAt?: number;
12
+ };
13
+
14
+ export type Progress = {
15
+ status: RunStatus;
16
+ issues: IssueProgress[];
17
+ handled: Handled[];
18
+ open?: number;
19
+ error?: string;
20
+ };
21
+
22
+ export type Question = {
23
+ issue: number;
24
+ title: string;
25
+ reason: string;
26
+ url: string;
27
+ repo: string;
28
+ pr?: number;
29
+ labels: { needsHuman: string; skip: string };
30
+ };
31
+
32
+ export type ClientMessage =
33
+ | { type: 'hello'; version: string }
34
+ | { type: 'start'; root: string; merge: boolean }
35
+ | { type: 'stop'; root: string }
36
+ | { type: 'questions'; root: string; id?: string };
37
+
38
+ export type WorkerMessage =
39
+ | { type: 'hello'; version: string }
40
+ | { type: 'runs'; runs: Record<string, Progress> }
41
+ | { type: 'questions'; root: string; questions: Question[]; id?: string }
42
+ | { type: 'questions-error'; root: string; message: string; id?: string }
43
+ | { type: 'error'; root: string; message: string };
44
+
45
+ export function going(progress: Progress | undefined): progress is Progress {
46
+ return progress?.status === 'running' || progress?.status === 'stopping';
47
+ }
48
+
49
+ export function isNewer(candidate: string, current: string): boolean {
50
+ const { core: left, prerelease: leftPrerelease } = parseVersion(candidate);
51
+ const { core: right, prerelease: rightPrerelease } = parseVersion(current);
52
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
53
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
54
+ if (difference !== 0) {
55
+ return difference > 0;
56
+ }
57
+ }
58
+ return isNewerPrerelease(leftPrerelease, rightPrerelease);
59
+ }
60
+
61
+ function isNewerPrerelease(left: string[], right: string[]): boolean {
62
+ if (left.length === 0 || right.length === 0) {
63
+ return left.length === 0 && right.length > 0;
64
+ }
65
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
66
+ const leftPart = left[index];
67
+ const rightPart = right[index];
68
+ if (leftPart === undefined || rightPart === undefined) {
69
+ return rightPart === undefined;
70
+ }
71
+ if (leftPart === rightPart) {
72
+ continue;
73
+ }
74
+ const leftNumeric = /^\d+$/.test(leftPart);
75
+ const rightNumeric = /^\d+$/.test(rightPart);
76
+ if (leftNumeric && rightNumeric) {
77
+ return Number(leftPart) > Number(rightPart);
78
+ }
79
+ if (leftNumeric || rightNumeric) {
80
+ return rightNumeric;
81
+ }
82
+ return leftPart > rightPart;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function parseVersion(version: string): { core: number[]; prerelease: string[] } {
88
+ const [core = '', ...rest] = version.replace(/\+.*$/, '').split('-');
89
+ return {
90
+ core: core.split('.').map(Number),
91
+ prerelease: rest.length > 0 ? rest.join('-').split('.') : [],
92
+ };
93
+ }
@@ -0,0 +1,56 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { going, type Question } from './protocol.ts';
3
+ import { CLOSED, type WorkerClient } from './worker.ts';
4
+
5
+ export function accepted(
6
+ client: WorkerClient,
7
+ root: string,
8
+ signal: AbortSignal | undefined,
9
+ ): Promise<void> {
10
+ return new Promise<void>((resolve, reject) => {
11
+ const finish = (error?: Error) => {
12
+ stop();
13
+ signal?.removeEventListener('abort', abort);
14
+ if (error === undefined) {
15
+ resolve();
16
+ } else {
17
+ reject(error);
18
+ }
19
+ };
20
+ const abort = () => finish(new Error('next_issue_start was cancelled'));
21
+ const stop = client.subscribe((event) => {
22
+ if (event.type === 'error' && event.root === root) {
23
+ finish(new Error(event.message));
24
+ } else if (event.type === 'closed') {
25
+ finish(new Error(CLOSED));
26
+ } else if (event.type === 'runs' && going(event.runs[root])) {
27
+ finish();
28
+ }
29
+ });
30
+ if (signal?.aborted) {
31
+ abort();
32
+ } else {
33
+ signal?.addEventListener('abort', abort, { once: true });
34
+ }
35
+ });
36
+ }
37
+
38
+ export function fresh(client: WorkerClient, root: string): Promise<Question[]> {
39
+ return new Promise<Question[]>((resolve, reject) => {
40
+ const id = randomUUID();
41
+ const ours = (event: { root: string; id?: string }) => event.root === root && event.id === id;
42
+ const stop = client.subscribe((event) => {
43
+ if (event.type === 'closed') {
44
+ stop();
45
+ reject(new Error(CLOSED));
46
+ } else if (event.type === 'questions' && ours(event)) {
47
+ stop();
48
+ resolve(event.questions);
49
+ } else if (event.type === 'questions-error' && ours(event)) {
50
+ stop();
51
+ reject(new Error(event.message));
52
+ }
53
+ });
54
+ client.send({ type: 'questions', root, id });
55
+ });
56
+ }
package/src/run.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { going, type Progress } from './protocol.ts';
2
+ import { accepted } from './requests.ts';
3
+ import { formatStatus, handledNote } from './status.ts';
4
+ import type { WorkerClient } from './worker.ts';
5
+
6
+ function report<Details>(text: string, details: Details) {
7
+ return { content: [{ type: 'text' as const, text }], details };
8
+ }
9
+
10
+ export async function statusOf(client: WorkerClient, root: string) {
11
+ await client.attach();
12
+ if (!client.connected) {
13
+ return report('no worker is running', { root });
14
+ }
15
+ const progress = client.runs[root];
16
+ if (progress === undefined) {
17
+ return report(`no run of ${root}`, { root });
18
+ }
19
+ const lines = [formatStatus(progress, Date.now()), ...progress.handled.map(handledNote)];
20
+ return report(lines.join('\n'), { root });
21
+ }
22
+
23
+ function sendStart(client: WorkerClient, root: string): void {
24
+ client.send({ type: 'start', root, merge: true });
25
+ }
26
+
27
+ export async function startRun(
28
+ client: WorkerClient,
29
+ root: string,
30
+ progress: Progress | undefined,
31
+ signal: AbortSignal | undefined,
32
+ ) {
33
+ if (going(progress)) {
34
+ return report(
35
+ `A run of ${root} is going already, so nothing started: ${formatStatus(progress, Date.now())}. It picks up new issues by itself.`,
36
+ { started: false, root },
37
+ );
38
+ }
39
+ const accepting = accepted(client, root, signal);
40
+ sendStart(client, root);
41
+ await accepting;
42
+ return report(`Started a next-issue run of ${root}.`, { started: true, root });
43
+ }
44
+
45
+ export function toggle(client: WorkerClient, root: string, progress: Progress | undefined): void {
46
+ if (going(progress)) {
47
+ client.send({ type: 'stop', root });
48
+ } else {
49
+ sendStart(client, root);
50
+ }
51
+ }
@@ -0,0 +1,32 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export function workerArgs(version: string): string[] {
6
+ return ['-y', `next-issue@${version === '0.0.0' ? 'latest' : version}`, 'serve'];
7
+ }
8
+
9
+ export function workerPaths(root: string): { socketPath: string; logPath: string } {
10
+ const stateDir = join(root, '.next-issue');
11
+ return {
12
+ socketPath: join(stateDir, 'worker.sock'),
13
+ logPath: join(stateDir, 'worker.log'),
14
+ };
15
+ }
16
+
17
+ export function spawnWorker(root: string, version: string): void {
18
+ const { logPath } = workerPaths(root);
19
+ mkdirSync(dirname(logPath), { recursive: true });
20
+ const log = openSync(logPath, 'a');
21
+ try {
22
+ const child = spawn('npx', workerArgs(version), {
23
+ cwd: root,
24
+ detached: true,
25
+ stdio: ['ignore', log, log],
26
+ });
27
+ child.on('error', () => {});
28
+ child.unref();
29
+ } finally {
30
+ closeSync(log);
31
+ }
32
+ }
package/src/status.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { Handled, IssueProgress, Progress, Question } from './protocol.ts';
2
+
3
+ function duration(ms: number): string {
4
+ const seconds = Math.max(0, Math.floor(ms / 1000));
5
+ const minutes = Math.floor(seconds / 60);
6
+ return minutes > 0 ? `${minutes}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
7
+ }
8
+
9
+ const OUTCOME_NOTES = { done: 'done', 'needs-human': 'needs a human', error: 'failed' } as const;
10
+
11
+ type Outcome = keyof typeof OUTCOME_NOTES;
12
+
13
+ function outcomes(progress: Progress): string {
14
+ const count = (outcome: Outcome): number =>
15
+ progress.handled.filter((item) => item.outcome === outcome).length;
16
+ return (Object.keys(OUTCOME_NOTES) as Outcome[])
17
+ .filter((outcome) => outcome === 'done' || count(outcome) > 0)
18
+ .map((outcome) => `${count(outcome)} ${OUTCOME_NOTES[outcome]}`)
19
+ .join(', ');
20
+ }
21
+
22
+ function failedNote(progress: Progress): string {
23
+ return `failed: ${progress.error ?? 'unknown error'}`;
24
+ }
25
+
26
+ export function handledNote(item: Handled): string {
27
+ return `#${item.issue} ${OUTCOME_NOTES[item.outcome]}`;
28
+ }
29
+
30
+ export type Note = { text: string; level: 'info' | 'warning' | 'error' };
31
+
32
+ export function statusNote(progress: Progress): Note | undefined {
33
+ if (progress.status === 'finished' || progress.status === 'stopped') {
34
+ return { text: `next-issue ${progress.status}`, level: 'info' };
35
+ }
36
+ if (progress.status === 'failed') {
37
+ return { text: `next-issue ${failedNote(progress)}`, level: 'error' };
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ export function cannotNote(action: 'start' | 'answer', message: string): Note {
43
+ return { text: `next-issue cannot ${action}: ${message}`, level: 'error' };
44
+ }
45
+
46
+ export function handledNotes(progress: Progress, from: number): Note[] {
47
+ return progress.handled.slice(from).map((item) => ({
48
+ text: `next-issue: ${handledNote(item)}`,
49
+ level: item.outcome === 'done' ? 'info' : 'warning',
50
+ }));
51
+ }
52
+
53
+ export function questionNotes(questions: Question[], known: number[] | undefined): Note[] {
54
+ if (known === undefined) {
55
+ return [];
56
+ }
57
+ return questions
58
+ .filter((question) => !known.includes(question.issue))
59
+ .map((question) => ({
60
+ text: `next-issue: #${question.issue} has a question: ${question.title}`,
61
+ level: 'warning',
62
+ }));
63
+ }
64
+
65
+ export function formatQuestions(count: number): string {
66
+ return `${count} ${count === 1 ? 'question' : 'questions'} waiting`;
67
+ }
68
+
69
+ function issueLine(item: IssueProgress, count: string, now: number): string {
70
+ const parts = [`#${item.issue}${count}`, item.stage ?? 'prepare', `${item.tools} tools`];
71
+ if (item.startedAt !== undefined) {
72
+ parts.push(duration(now - item.startedAt));
73
+ }
74
+ return parts.join(' · ');
75
+ }
76
+
77
+ export function formatStatus(progress: Progress, now: number): string {
78
+ const running = progress.issues ?? [];
79
+ switch (progress.status) {
80
+ case 'running': {
81
+ if (running.length === 0) {
82
+ return 'looking for issues';
83
+ }
84
+ const count = progress.open === undefined ? '' : `/${progress.open}`;
85
+ return running.map((item) => issueLine(item, count, now)).join(' | ');
86
+ }
87
+ case 'stopping': {
88
+ const issues = running.map((item) => `#${item.issue}`);
89
+ return `${issues.length === 0 ? '#?' : issues.join(' ')} · stopping`;
90
+ }
91
+ case 'stopped':
92
+ case 'finished':
93
+ return `${progress.status}: ${outcomes(progress)}`;
94
+ case 'failed':
95
+ return failedNote(progress);
96
+ }
97
+ }
package/src/worker.ts CHANGED
@@ -1,58 +1,15 @@
1
- import { spawn } from 'node:child_process';
2
- import { closeSync, mkdirSync, openSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
1
  import WebSocket from 'ws';
5
-
6
- export type RunStatus = 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
7
-
8
- export type Handled = { issue: number; outcome: 'done' | 'needs-human' | 'error' };
9
-
10
- export type IssueProgress = {
11
- issue: number;
12
- title?: string;
13
- stage?: string;
14
- tools: number;
15
- lastTool?: string;
16
- startedAt?: number;
17
- };
18
-
19
- export type Progress = {
20
- status: RunStatus;
21
- issues: IssueProgress[];
22
- handled: Handled[];
23
- open?: number;
24
- error?: string;
25
- };
26
-
27
- export type Question = {
28
- issue: number;
29
- title: string;
30
- reason: string;
31
- url: string;
32
- repo: string;
33
- pr?: number;
34
- labels: { needsHuman: string; skip: string };
35
- };
36
-
37
- export type ClientMessage =
38
- | { type: 'hello'; version: string }
39
- | { type: 'start'; root: string; merge: boolean }
40
- | { type: 'stop'; root: string }
41
- | { type: 'questions'; root: string; id?: string };
42
-
43
- export type WorkerMessage =
44
- | { type: 'hello'; version: string }
45
- | { type: 'runs'; runs: Record<string, Progress> }
46
- | { type: 'questions'; root: string; questions: Question[]; id?: string }
47
- | { type: 'questions-error'; root: string; message: string; id?: string }
48
- | { type: 'error'; root: string; message: string };
49
-
50
- export type WorkerEvent =
51
- | { type: 'runs'; runs: Record<string, Progress> }
52
- | { type: 'questions'; root: string; questions: Question[]; id?: string }
53
- | { type: 'questions-error'; root: string; message: string; id?: string }
54
- | { type: 'error'; root: string; message: string }
55
- | { type: 'closed' };
2
+ import { errorText } from './errors.ts';
3
+ import {
4
+ type ClientMessage,
5
+ going,
6
+ isNewer,
7
+ type Progress,
8
+ type Question,
9
+ type WorkerMessage,
10
+ } from './protocol.ts';
11
+
12
+ export type WorkerEvent = Exclude<WorkerMessage, { type: 'hello' }> | { type: 'closed' };
56
13
 
57
14
  export type WorkerOptions = {
58
15
  socketPath: string;
@@ -66,56 +23,9 @@ const START_TIMEOUT_MS = 120_000;
66
23
  const RECONNECT_MS = 5_000;
67
24
  const POLL_MS = 200;
68
25
 
69
- export function isNewer(candidate: string, current: string): boolean {
70
- const [leftCore = '', ...leftRest] = candidate.replace(/\+.*$/, '').split('-');
71
- const [rightCore = '', ...rightRest] = current.replace(/\+.*$/, '').split('-');
72
- const left = leftCore.split('.').map(Number);
73
- const right = rightCore.split('.').map(Number);
74
- for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
75
- const difference = (left[index] ?? 0) - (right[index] ?? 0);
76
- if (difference !== 0) {
77
- return difference > 0;
78
- }
79
- }
80
- const leftPrerelease = leftRest.length > 0 ? leftRest.join('-').split('.') : [];
81
- const rightPrerelease = rightRest.length > 0 ? rightRest.join('-').split('.') : [];
82
- if (leftPrerelease.length === 0 || rightPrerelease.length === 0) {
83
- return leftPrerelease.length === 0 && rightPrerelease.length > 0;
84
- }
85
- for (let index = 0; index < Math.max(leftPrerelease.length, rightPrerelease.length); index += 1) {
86
- const leftPart = leftPrerelease[index];
87
- const rightPart = rightPrerelease[index];
88
- if (leftPart === undefined || rightPart === undefined) {
89
- return rightPart === undefined;
90
- }
91
- if (leftPart === rightPart) {
92
- continue;
93
- }
94
- const leftNumeric = /^\d+$/.test(leftPart);
95
- const rightNumeric = /^\d+$/.test(rightPart);
96
- if (leftNumeric && rightNumeric) {
97
- return Number(leftPart) > Number(rightPart);
98
- }
99
- if (leftNumeric || rightNumeric) {
100
- return rightNumeric;
101
- }
102
- return leftPart > rightPart;
103
- }
104
- return false;
105
- }
106
-
107
- export function workerArgs(version: string): string[] {
108
- return ['-y', `next-issue@${version === '0.0.0' ? 'latest' : version}`, 'serve'];
109
- }
110
-
111
- export function workerPaths(root: string): { socketPath: string; logPath: string } {
112
- return {
113
- socketPath: join(root, '.next-issue', 'worker.sock'),
114
- logPath: join(root, '.next-issue', 'worker.log'),
115
- };
116
- }
26
+ export const CLOSED = 'the worker closed the connection';
117
27
 
118
- export function checkSocketPath(path: string, platform = process.platform): void {
28
+ function checkSocketPath(path: string, platform = process.platform): void {
119
29
  const size = platform === 'linux' ? 108 : 104;
120
30
  const length = Buffer.byteLength(path);
121
31
  if (length >= size) {
@@ -125,20 +35,11 @@ export function checkSocketPath(path: string, platform = process.platform): void
125
35
  }
126
36
  }
127
37
 
128
- export function spawnWorker(root: string, version: string): void {
129
- const { logPath } = workerPaths(root);
130
- mkdirSync(dirname(logPath), { recursive: true });
131
- const log = openSync(logPath, 'a');
38
+ function parseMessage(text: string): WorkerMessage | undefined {
132
39
  try {
133
- const child = spawn('npx', workerArgs(version), {
134
- cwd: root,
135
- detached: true,
136
- stdio: ['ignore', log, log],
137
- });
138
- child.on('error', () => {});
139
- child.unref();
140
- } finally {
141
- closeSync(log);
40
+ return JSON.parse(text) as WorkerMessage;
41
+ } catch {
42
+ return undefined;
142
43
  }
143
44
  }
144
45
 
@@ -160,6 +61,22 @@ export class WorkerClient {
160
61
  this.options = options;
161
62
  }
162
63
 
64
+ private get startTimeoutMs(): number {
65
+ return this.options.startTimeoutMs ?? START_TIMEOUT_MS;
66
+ }
67
+
68
+ private get reconnectMs(): number {
69
+ return this.options.reconnectMs ?? RECONNECT_MS;
70
+ }
71
+
72
+ private get busy(): boolean {
73
+ return Object.values(this.runs).some(going);
74
+ }
75
+
76
+ private get outdated(): boolean {
77
+ return this.workerVersion !== undefined && isNewer(this.options.version, this.workerVersion);
78
+ }
79
+
163
80
  get connected(): boolean {
164
81
  return this.socket?.readyState === WebSocket.OPEN;
165
82
  }
@@ -182,22 +99,11 @@ export class WorkerClient {
182
99
 
183
100
  async ensure(): Promise<void> {
184
101
  if (!this.connected) {
185
- this.connecting ??= this.start().finally(() => {
186
- this.connecting = undefined;
187
- });
188
- await this.connecting;
102
+ await this.connect();
189
103
  }
190
- if (this.workerVersion !== undefined && isNewer(this.options.version, this.workerVersion)) {
191
- const busy = Object.values(this.runs).some(
192
- (run) => run.status === 'running' || run.status === 'stopping',
193
- );
194
- if (!busy) {
195
- await this.waitForClose();
196
- this.connecting ??= this.start().finally(() => {
197
- this.connecting = undefined;
198
- });
199
- await this.connecting;
200
- }
104
+ if (this.outdated && !this.busy) {
105
+ await this.waitForClose();
106
+ await this.connect();
201
107
  }
202
108
  }
203
109
 
@@ -210,6 +116,13 @@ export class WorkerClient {
210
116
  socket?.terminate();
211
117
  }
212
118
 
119
+ private connect(): Promise<void> {
120
+ this.connecting ??= this.start().finally(() => {
121
+ this.connecting = undefined;
122
+ });
123
+ return this.connecting;
124
+ }
125
+
213
126
  private async start(): Promise<void> {
214
127
  checkSocketPath(this.options.socketPath);
215
128
  try {
@@ -218,7 +131,11 @@ export class WorkerClient {
218
131
  } catch {
219
132
  this.options.spawnWorker(this.options.version);
220
133
  }
221
- const deadline = Date.now() + (this.options.startTimeoutMs ?? START_TIMEOUT_MS);
134
+ await this.waitForStart();
135
+ }
136
+
137
+ private async waitForStart(): Promise<void> {
138
+ const deadline = Date.now() + this.startTimeoutMs;
222
139
  for (;;) {
223
140
  await delay(POLL_MS);
224
141
  try {
@@ -226,9 +143,7 @@ export class WorkerClient {
226
143
  return;
227
144
  } catch (error) {
228
145
  if (Date.now() >= deadline) {
229
- throw new Error(
230
- `the next-issue worker did not start: ${error instanceof Error ? error.message : String(error)}`,
231
- );
146
+ throw new Error(`the next-issue worker did not start: ${errorText(error)}`);
232
147
  }
233
148
  }
234
149
  }
@@ -244,52 +159,59 @@ export class WorkerClient {
244
159
  }
245
160
  });
246
161
  socket.on('message', (data) => {
247
- let message: WorkerMessage;
248
- try {
249
- message = JSON.parse(String(data)) as WorkerMessage;
250
- } catch {
162
+ const message = parseMessage(String(data));
163
+ if (message === undefined) {
251
164
  return;
252
165
  }
253
- if (message.type === 'hello') {
254
- this.workerVersion = message.version;
255
- } else if (message.type === 'runs') {
256
- this.runs = message.runs;
257
- this.emit({ type: 'runs', runs: message.runs });
258
- if (!hello) {
259
- hello = true;
260
- resolve();
261
- }
262
- } else if (message.type === 'questions') {
263
- this.questions[message.root] = message.questions;
264
- this.emit(message);
265
- } else if (message.type === 'error' || message.type === 'questions-error') {
266
- this.emit(message);
166
+ this.receive(message);
167
+ if (message.type === 'runs' && !hello) {
168
+ hello = true;
169
+ resolve();
267
170
  }
268
171
  });
269
172
  socket.once('open', () => {
270
173
  this.socket = socket;
271
- socket.send(JSON.stringify({ type: 'hello', version: this.options.version }));
174
+ this.send({ type: 'hello', version: this.options.version });
272
175
  });
273
176
  socket.once('close', () => {
274
- if (this.socket === socket) {
275
- this.socket = undefined;
276
- this.emit({ type: 'closed' });
277
- this.scheduleReconnect();
278
- }
177
+ this.release(socket);
279
178
  if (!hello) {
280
- reject(new Error('the worker closed the connection'));
179
+ reject(new Error(CLOSED));
281
180
  }
282
181
  });
283
182
  });
284
183
  }
285
184
 
185
+ private release(socket: WebSocket): void {
186
+ if (this.socket !== socket) {
187
+ return;
188
+ }
189
+ this.socket = undefined;
190
+ this.emit({ type: 'closed' });
191
+ this.scheduleReconnect();
192
+ }
193
+
194
+ private receive(message: WorkerMessage): void {
195
+ if (message.type === 'hello') {
196
+ this.workerVersion = message.version;
197
+ } else if (message.type === 'runs') {
198
+ this.runs = message.runs;
199
+ this.emit(message);
200
+ } else if (message.type === 'questions') {
201
+ this.questions[message.root] = message.questions;
202
+ this.emit(message);
203
+ } else if (message.type === 'error' || message.type === 'questions-error') {
204
+ this.emit(message);
205
+ }
206
+ }
207
+
286
208
  private waitForClose(): Promise<void> {
287
209
  const socket = this.socket;
288
210
  if (socket === undefined || socket.readyState === WebSocket.CLOSED) {
289
211
  return Promise.resolve();
290
212
  }
291
213
  return new Promise((resolve) => {
292
- const timer = setTimeout(resolve, this.options.startTimeoutMs ?? START_TIMEOUT_MS);
214
+ const timer = setTimeout(resolve, this.startTimeoutMs);
293
215
  socket.once('close', () => {
294
216
  clearTimeout(timer);
295
217
  resolve();
@@ -306,7 +228,7 @@ export class WorkerClient {
306
228
  if (!this.connected && this.connecting === undefined) {
307
229
  void this.attach();
308
230
  }
309
- }, this.options.reconnectMs ?? RECONNECT_MS);
231
+ }, this.reconnectMs);
310
232
  this.reconnectTimer.unref?.();
311
233
  }
312
234