next-issue 2.11.0 → 2.13.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/README.md CHANGED
@@ -248,6 +248,13 @@ jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
248
248
  .next-issue/runs/*.jsonl
249
249
  ```
250
250
 
251
+ `next-issue logs` shows the newest run log, one readable line per event, and
252
+ then prints new events as the run adds them, until `Ctrl-C`. In a terminal it
253
+ starts with the last events that fit the screen; in a pipe it starts with all
254
+ of them. It stops when the reader of the pipe closes it; on Linux it notices
255
+ this only at the next event. With `--json` it prints the raw JSON lines. This also shows a run of
256
+ the worker, whose progress goes to no terminal.
257
+
251
258
  Three levels of console output:
252
259
 
253
260
  | Level | Shows |
@@ -408,13 +415,14 @@ state of an issue that a label still holds back stays as it was.
408
415
  | `init` | Write the config file with the defaults |
409
416
  | `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
410
417
  | `stop` | Stop the worker, see [Worker](#worker) |
418
+ | `logs` | Show the latest run log and follow it, see [Observability](#observability) |
411
419
  | `--force` | Replace an existing config file, with `init` |
412
420
  | `--once` | Stop after the first handled issue |
413
421
  | `--max <n>` | Handle at most n issues |
414
422
  | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
415
423
  | `--watch` | Keep running, and read the open issues again after each pass |
416
424
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
417
- | `--json` | Print the machine summary instead of the text one |
425
+ | `--json` | Print the machine summary instead of the text one, or the raw JSON lines with `logs` |
418
426
  | `--verbose` | Show every command and all agent output |
419
427
  | `--quiet` | Show only the milestones and the summary |
420
428
  | `--help` | Show the option list |
@@ -471,14 +479,17 @@ to it. The worker:
471
479
  issues;
472
480
  - sends every client a `questions` message for each repository: the open
473
481
  issues with the `needsHuman` label, each with its number, title, the reason
474
- of the hand-over and the url of the comment that holds it. It reads them
482
+ of the hand-over, the url of the comment that holds it, the `owner/name`
483
+ repository, the pull request when there is one, and the `needsHuman` and
484
+ `skip` labels. It reads them
475
485
  from GitHub, so the list is the same after a restart. It sends them when a
476
486
  client connects, for each repository it knows, after each issue of a run
477
487
  ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
478
488
  An issue that no longer has the label leaves the list. An issue with the
479
489
  label but no hand-over comment is in the list with an empty reason and url.
480
490
  The issues are read one at a time. A client never gets an older list after
481
- a newer one. When the list cannot be read, the client gets
491
+ a newer one. When the client adds an `"id"` to its message, the list that
492
+ answers it carries the same `id` and is read after the message arrived. When the list cannot be read, the client gets
482
493
  `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
483
494
  - stops a run, when a client asks, like a second `Ctrl-C`: after the current
484
495
  round, with the state kept;
package/dist/args.mjs CHANGED
@@ -4,13 +4,14 @@ export const USAGE = `next-issue [command] [options]
4
4
  init write ${CONFIG_FILE} with the defaults
5
5
  serve run the worker that pi extensions talk to
6
6
  stop stop the worker
7
+ logs show the latest run log and follow it
7
8
 
8
9
  --once stop after the first handled issue
9
10
  --max <n> handle at most n issues
10
11
  --merge squash-merge the pull request after the approval
11
12
  --watch keep running and read the issues again after each pass
12
13
  --reset drop the saved budgets and findings first
13
- --json print the machine summary instead of the text one
14
+ --json print the machine summary instead of the text one, or raw lines with logs
14
15
  --force replace an existing config file, with init
15
16
  --verbose show every command and all agent output
16
17
  --quiet show only the milestones and the summary
@@ -31,7 +32,7 @@ export function parseArgs(argv) {
31
32
  for (let index = 0; index < argv.length; index += 1) {
32
33
  const arg = argv[index];
33
34
  if (index === 0 && !arg.startsWith('-')) {
34
- if (arg !== 'init' && arg !== 'serve' && arg !== 'stop') {
35
+ if (arg !== 'init' && arg !== 'serve' && arg !== 'stop' && arg !== 'logs') {
35
36
  throw new Error(`Unknown command: ${arg}`);
36
37
  }
37
38
  args.command = arg;
package/dist/cli.mjs CHANGED
@@ -3,6 +3,7 @@ import { parseArgs, USAGE } from './args.mjs';
3
3
  import { writeDefaultConfig } from './config.mjs';
4
4
  import { ensureIgnored, repoRoot, WORKTREE_DIR } from './git.mjs';
5
5
  import { listenForInterrupts } from './interrupt.mjs';
6
+ import { followLog, newestRunLog } from './logs.mjs';
6
7
  import { message } from './observe.mjs';
7
8
  import { runRepo } from './run.mjs';
8
9
  import { serve, socketPaths, stopWorker } from './serve.mjs';
@@ -33,12 +34,24 @@ async function main() {
33
34
  if (args.command === 'init') {
34
35
  return init(root, args.force);
35
36
  }
37
+ if (args.command === 'logs') {
38
+ return logs(root, args.json);
39
+ }
36
40
  const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
37
41
  return runRepo(root, args, {
38
42
  interrupts,
39
43
  print: (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary)),
40
44
  });
41
45
  }
46
+ async function logs(root, json) {
47
+ const file = await newestRunLog(root);
48
+ if (file === undefined) {
49
+ await write(process.stderr, `next-issue: no run log in ${STATE_DIR}/runs\n`);
50
+ return 1;
51
+ }
52
+ const tail = process.stdout.isTTY ? Math.max(1, (process.stdout.rows || 24) - 1) : undefined;
53
+ return followLog(file, { json, tail }, (text) => write(process.stdout, text));
54
+ }
42
55
  async function init(root, force) {
43
56
  const path = await writeDefaultConfig(root, force);
44
57
  await write(process.stdout, `wrote ${path}\n`);
package/dist/handover.mjs CHANGED
@@ -33,6 +33,9 @@ export async function waitingQuestions(repo, config, ports) {
33
33
  title: issue.title,
34
34
  reason: comment?.body.slice(HAND_OVER.length).trim() ?? '',
35
35
  url: comment?.url ?? '',
36
+ repo: `${repo.owner}/${repo.name}`,
37
+ ...(pr === undefined ? {} : { pr }),
38
+ labels: { needsHuman: config.labels.needsHuman, skip: config.labels.skip },
36
39
  });
37
40
  }
38
41
  return questions;
package/dist/logs.mjs ADDED
@@ -0,0 +1,99 @@
1
+ import { writeSync } from 'node:fs';
2
+ import { open, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { StringDecoder } from 'node:string_decoder';
5
+ import { render } from './observe.mjs';
6
+ import { STATE_DIR } from './state.mjs';
7
+ const POLL_MS = 200;
8
+ export async function newestRunLog(root) {
9
+ const dir = join(root, STATE_DIR, 'runs');
10
+ let names;
11
+ try {
12
+ names = await readdir(dir);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ const newest = names
18
+ .filter((name) => name.endsWith('.jsonl'))
19
+ .sort()
20
+ .at(-1);
21
+ return newest === undefined ? undefined : join(dir, newest);
22
+ }
23
+ export async function followLog(file, options, print) {
24
+ const handle = await open(file, 'r');
25
+ let stopped = false;
26
+ let wake;
27
+ const stop = () => {
28
+ stopped = true;
29
+ wake?.();
30
+ };
31
+ process.on('SIGINT', stop);
32
+ process.stdout.on('error', stop);
33
+ const show = (line) => {
34
+ if (options.json) {
35
+ return `${line}\n`;
36
+ }
37
+ try {
38
+ return render(JSON.parse(line));
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ };
44
+ const decoder = new StringDecoder('utf8');
45
+ let position = 0;
46
+ let rest = '';
47
+ let first = true;
48
+ try {
49
+ while (!stopped) {
50
+ const buffer = Buffer.alloc(64 * 1024);
51
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
52
+ if (bytesRead > 0) {
53
+ position += bytesRead;
54
+ rest += decoder.write(buffer.subarray(0, bytesRead));
55
+ if (bytesRead === buffer.length) {
56
+ continue;
57
+ }
58
+ }
59
+ const lines = rest.split('\n');
60
+ rest = lines.pop();
61
+ let shown = lines.filter((line) => line.trim().length > 0);
62
+ if (first && options.tail !== undefined) {
63
+ shown = shown.slice(-options.tail);
64
+ }
65
+ first = false;
66
+ if (shown.length > 0) {
67
+ await print(shown.map(show).join(''));
68
+ }
69
+ try {
70
+ writeSync(process.stdout.fd, '');
71
+ }
72
+ catch {
73
+ stop();
74
+ }
75
+ if (stopped) {
76
+ break;
77
+ }
78
+ await new Promise((resolve) => {
79
+ const timer = setTimeout(resolve, POLL_MS);
80
+ wake = () => {
81
+ clearTimeout(timer);
82
+ resolve();
83
+ };
84
+ });
85
+ wake = undefined;
86
+ }
87
+ }
88
+ catch (error) {
89
+ if (!stopped && error.code !== 'EPIPE') {
90
+ throw error;
91
+ }
92
+ }
93
+ finally {
94
+ process.off('SIGINT', stop);
95
+ process.stdout.off('error', stop);
96
+ await handle.close();
97
+ }
98
+ return 0;
99
+ }
package/dist/observe.mjs CHANGED
@@ -171,7 +171,7 @@ export async function pruneRuns(root, keepDays) {
171
171
  export function message(error) {
172
172
  return error instanceof Error ? error.message : String(error);
173
173
  }
174
- function render(record) {
174
+ export function render(record) {
175
175
  const time = new Date(String(record.ts)).toTimeString().slice(0, 8);
176
176
  const issue = record.issue === undefined ? '' : ` #${record.issue}`;
177
177
  const extras = Object.entries(record)
package/dist/serve.mjs CHANGED
@@ -113,7 +113,7 @@ export async function startWorker(options) {
113
113
  send(socket, value);
114
114
  }
115
115
  };
116
- const sendQuestions = async (root, targets) => {
116
+ const sendQuestions = async (root, targets, id) => {
117
117
  remember(root);
118
118
  const read = (reads.get(root) ?? 0) + 1;
119
119
  reads.set(root, read);
@@ -126,10 +126,14 @@ export async function startWorker(options) {
126
126
  }
127
127
  for (const socket of targets()) {
128
128
  const seen = delivered.get(socket) ?? new Map();
129
- if ((seen.get(root) ?? 0) < read) {
130
- seen.set(root, read);
129
+ const last = seen.get(root);
130
+ if (last === undefined || last.read < read) {
131
+ seen.set(root, { read, value });
131
132
  delivered.set(socket, seen);
132
- send(socket, value);
133
+ send(socket, id === undefined ? value : { ...value, id });
134
+ }
135
+ else if (id !== undefined) {
136
+ send(socket, { ...last.value, id });
133
137
  }
134
138
  }
135
139
  };
@@ -240,7 +244,7 @@ export async function startWorker(options) {
240
244
  stop(value.root);
241
245
  }
242
246
  else if (value.type === 'questions') {
243
- void sendQuestions(value.root, () => [socket]);
247
+ void sendQuestions(value.root, () => [socket], value.id);
244
248
  }
245
249
  });
246
250
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",