@irythill/orq 0.3.0 → 0.5.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 +55 -11
- package/README.pt-BR.md +47 -11
- package/dist/agents.js +10 -0
- package/dist/ci.js +241 -0
- package/dist/cli.js +9 -1
- package/dist/commands/agent.js +86 -13
- package/dist/commands/block.js +46 -6
- package/dist/commands/board.js +109 -18
- package/dist/commands/ci.js +48 -0
- package/dist/commands/claim.js +148 -12
- package/dist/commands/completion.js +4 -3
- package/dist/commands/gc.js +1 -1
- package/dist/commands/handoff.js +259 -0
- package/dist/commands/pick.js +19 -4
- package/dist/commands/quota.js +34 -9
- package/dist/commands/rounds.js +132 -0
- package/dist/commands/stats.js +1 -1
- package/dist/commands/verify.js +2 -0
- package/dist/commands/wait.js +2 -0
- package/dist/config.js +5 -2
- package/dist/diagnose.js +105 -0
- package/dist/identity.js +55 -0
- package/dist/notice.js +282 -0
- package/dist/pick.js +137 -11
- package/dist/quota.js +83 -21
- package/dist/roles.js +11 -2
- package/dist/rounds.js +176 -0
- package/dist/stats.js +4 -3
- package/dist/testrun.js +4 -10
- package/package.json +1 -1
- package/skills/issue/SKILL.md +48 -6
- package/skills/orq-setup/SKILL.md +1 -1
- package/templates/workspace.CLAUDE.md +8 -0
package/dist/commands/agent.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// under its memory cap (src/process.ts), in a herdr tab, a tmux window or the background (src/terminal.ts).
|
|
3
3
|
// Writes <prefix>.out.md (final answer) / .log / .id (session, for -R) / .pane / .done ("status=ok|fail rc=N at=...").
|
|
4
4
|
// Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
|
|
5
|
-
// "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by
|
|
5
|
+
// "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by src/notice.ts.
|
|
6
6
|
// The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
|
|
7
7
|
// -T (or machine.tui / HERDR_AGENT_TUI=1): the agent's own screen (its `tui` template) in a herdr tab or tmux window,
|
|
8
8
|
// same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
|
|
@@ -14,16 +14,18 @@ import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync,
|
|
|
14
14
|
import { basename, dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { claudeAccounts } from "../accounts.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { gateFor } from "../diagnose.js";
|
|
18
|
+
import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
|
|
19
|
+
import { getPath, loadConfig } from "../config.js";
|
|
19
20
|
import { writeAtomic } from "../fsutil.js";
|
|
20
21
|
import { m } from "../i18n.js";
|
|
21
22
|
import { alive } from "../lock.js";
|
|
22
23
|
import { stateDir } from "../paths.js";
|
|
23
24
|
import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
24
25
|
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
26
|
+
import { ackPrefix } from "../notice.js";
|
|
25
27
|
import { dropWorktree, notify } from "../testrun.js";
|
|
26
|
-
export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session] [-T] -- <prompt.md> run a child agent";
|
|
28
|
+
export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F [-F]]] [-T] -- <prompt.md> run a child agent";
|
|
27
29
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
28
30
|
const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
|
|
29
31
|
const git = (dir, ...a) => {
|
|
@@ -35,8 +37,8 @@ export async function agent(argv) {
|
|
|
35
37
|
return runJob(argv[1]);
|
|
36
38
|
if (argv[0] === "--turn")
|
|
37
39
|
return turn(argv[1], argv[2], argv.slice(3));
|
|
38
|
-
let a = process.env.AGENT || "
|
|
39
|
-
let ro = false, tui = false, prompt;
|
|
40
|
+
let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
|
|
41
|
+
let ro = false, tui = false, force = 0, prompt;
|
|
40
42
|
const inc = [];
|
|
41
43
|
for (let i = 0; i < argv.length; i++) {
|
|
42
44
|
const x = argv[i], v = () => argv[++i] ?? "";
|
|
@@ -72,6 +74,8 @@ export async function agent(argv) {
|
|
|
72
74
|
ro = true;
|
|
73
75
|
else if (x === "-T")
|
|
74
76
|
tui = true;
|
|
77
|
+
else if (x === "-F")
|
|
78
|
+
force++;
|
|
75
79
|
else if (!x.startsWith("-") && prompt === undefined)
|
|
76
80
|
prompt = x;
|
|
77
81
|
else {
|
|
@@ -88,6 +92,11 @@ export async function agent(argv) {
|
|
|
88
92
|
console.error(`${m("missing", "faltou")} -${f}`);
|
|
89
93
|
return 2;
|
|
90
94
|
}
|
|
95
|
+
// No default agent: an empty $(orq pick ...) means no candidate, and must not start anything.
|
|
96
|
+
if (!a) {
|
|
97
|
+
console.error(m("missing -a (if it came from orq pick, it found no candidate): nothing started", "faltou -a (se veio do orq pick, ele não achou candidato): nada foi iniciado"));
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
91
100
|
a = ALIASES[a] ?? a;
|
|
92
101
|
const { cfg } = loadConfig();
|
|
93
102
|
const def = agentDef(cfg, a);
|
|
@@ -121,6 +130,31 @@ export async function agent(argv) {
|
|
|
121
130
|
return 2;
|
|
122
131
|
}
|
|
123
132
|
const repo = resolve(d), O = resolve(o);
|
|
133
|
+
// Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
|
|
134
|
+
// not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
|
|
135
|
+
if (resume) {
|
|
136
|
+
const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
|
|
137
|
+
let n = 0;
|
|
138
|
+
try {
|
|
139
|
+
n = Number(readFileSync(cf, "utf8")) || 0;
|
|
140
|
+
}
|
|
141
|
+
catch { /* first follow-up */ }
|
|
142
|
+
if (n >= max && !force) {
|
|
143
|
+
console.error(m(`round limit: session ${resume} already had ${n} follow-up(s) (policy.max_rounds = ${max}). Stop and tell the user; with their OK, run \`orq rounds <ID> --diagnose\`, then repeat with -F.`, `limite de rodadas: a sessão ${resume} já teve ${n} follow-up(s) (policy.max_rounds = ${max}). Pare e avise o usuário; com o OK dele, rode \`orq rounds <ID> --diagnose\` e repita com -F.`));
|
|
144
|
+
return 4;
|
|
145
|
+
}
|
|
146
|
+
// -F past the limit also needs the watchdog's diagnosis of this round count (-F -F skips it: the user's own call).
|
|
147
|
+
// The issue folder is the prefix's folder, .orquestra/<ID>/ (where the .rounds-<session> counter lives).
|
|
148
|
+
if (n >= max && force === 1 && basename(dirname(dirname(O))) === ".orquestra") {
|
|
149
|
+
const g = gateFor(dirname(O)), id = basename(dirname(O));
|
|
150
|
+
if (g.verdict !== "ok") {
|
|
151
|
+
console.error(m(`no current diagnosis: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md is older than the latest fix/review` : `diagnosis-${g.rounds}.md does not exist`}. Run \`orq rounds ${id} --diagnose\` first, read it, and ask the user. -F -F skips this check.`, `sem diagnóstico atual: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md é mais antigo que o último fix/revisão` : `diagnosis-${g.rounds}.md não existe`}. Rode \`orq rounds ${id} --diagnose\` antes, leia e consulte o usuário. -F -F pula esta checagem.`));
|
|
152
|
+
return 4;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
mkdirSync(dirname(O), { recursive: true });
|
|
156
|
+
writeFileSync(cf, String(n + 1));
|
|
157
|
+
}
|
|
124
158
|
let P = resolve(prompt);
|
|
125
159
|
if (!existsSync(repo)) {
|
|
126
160
|
console.error(m(`no folder ${repo}`, `pasta ${repo} não existe`));
|
|
@@ -158,6 +192,7 @@ export async function agent(argv) {
|
|
|
158
192
|
}
|
|
159
193
|
catch { /* first run or ended */ }
|
|
160
194
|
if (tui && resume && alive(pid) && screenOpen(open)) {
|
|
195
|
+
ackPrefix(O); // a follow-up on the issue: its failure notices were seen
|
|
161
196
|
for (const s of [".done", ".out.md"])
|
|
162
197
|
rmSync(O + s, { force: true });
|
|
163
198
|
typeInto(open, readFileSync(P, "utf8"));
|
|
@@ -166,7 +201,7 @@ export async function agent(argv) {
|
|
|
166
201
|
}
|
|
167
202
|
for (const s of [".done", ".out.md", ".pid"])
|
|
168
203
|
rmSync(O + s, { force: true });
|
|
169
|
-
const throwaway =
|
|
204
|
+
const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
|
|
170
205
|
const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
|
|
171
206
|
const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
172
207
|
const wg = git(repo, "rev-parse", "--absolute-git-dir");
|
|
@@ -199,7 +234,7 @@ export async function agent(argv) {
|
|
|
199
234
|
const vars = {
|
|
200
235
|
prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
|
|
201
236
|
prompt_hint: `Read the file ${PF} (in the current folder, with your file-reading tool): it holds your complete instructions for this task. Follow them. ${PF} is not part of the code: don't review or edit it.`,
|
|
202
|
-
out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
|
|
237
|
+
out: `${O}.out.md`, dir, repo, base, title: t, model: model === "auto" ? "" : model, effort, session,
|
|
203
238
|
gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
|
|
204
239
|
hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
|
|
205
240
|
hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
|
|
@@ -229,11 +264,13 @@ export async function agent(argv) {
|
|
|
229
264
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
230
265
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
231
266
|
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
|
|
267
|
+
okExit: def.ok_exit, filter: def.filter, noReview: def.no_review,
|
|
232
268
|
};
|
|
233
269
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
234
270
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
235
271
|
writeFileSync(`${O}.run.sh`, `#!/usr/bin/env bash\n# orq agent (generated; the run itself is \`orq agent --run ${O}.job.json\`) --unit=${unit} \n` +
|
|
236
272
|
`cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
|
|
273
|
+
ackPrefix(O); // starting work on the issue again: its failure notices were seen
|
|
237
274
|
const where = launch(host, { group: g || "agents", title: t, dir: repo, argv: [process.execPath, CLI, "agent", "--run", `${O}.job.json`], id: unit, keep: job.keep });
|
|
238
275
|
writeFileSync(`${O}.pane`, `${where}\n`);
|
|
239
276
|
console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
|
|
@@ -303,10 +340,11 @@ function turn(file, kind, rest) {
|
|
|
303
340
|
writeFileSync(`${O}.out.md`, answer);
|
|
304
341
|
if (sid)
|
|
305
342
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
306
|
-
const
|
|
343
|
+
const noReview = noReviewMatch(j.noReview, answer);
|
|
344
|
+
const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
|
|
307
345
|
writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
308
346
|
if (j.parent)
|
|
309
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md
|
|
347
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
|
|
310
348
|
}
|
|
311
349
|
catch (e) {
|
|
312
350
|
console.error(`orq agent --turn: ${e.message}`);
|
|
@@ -332,6 +370,27 @@ function lastClaudeText(transcript) {
|
|
|
332
370
|
catch { /* no transcript */ }
|
|
333
371
|
return text;
|
|
334
372
|
}
|
|
373
|
+
/** agents.<name>.no_review tested against the start of the answer (first line) or, for a short answer, the whole
|
|
374
|
+
* thing — not anywhere in a long one, so a review that quotes the phrase while discussing real code doesn't match.
|
|
375
|
+
* An invalid pattern is logged and ignored rather than killing the runner. */
|
|
376
|
+
function noReviewMatch(pattern, answer) {
|
|
377
|
+
if (pattern === undefined)
|
|
378
|
+
return false;
|
|
379
|
+
if (typeof pattern !== "string" || !pattern.trim()) {
|
|
380
|
+
console.error(`orq agent: ignoring invalid agents.<name>.no_review ${JSON.stringify(pattern)}: expected a non-empty regex string`);
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
let re;
|
|
384
|
+
try {
|
|
385
|
+
re = new RegExp(pattern, "m");
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
console.error(`orq agent: ignoring invalid agents.<name>.no_review pattern ${JSON.stringify(pattern)}: ${e.message}`);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
const first = answer.split(/\r?\n/, 1)[0] ?? "";
|
|
392
|
+
return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
|
|
393
|
+
}
|
|
335
394
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
336
395
|
/** Splits a stream into lines for `each`. */
|
|
337
396
|
function lines(s, each) {
|
|
@@ -355,7 +414,7 @@ async function runJob(file) {
|
|
|
355
414
|
const stateF = join(stateDir(), "agents", `${j.unit}.json`);
|
|
356
415
|
const at = new Date().toISOString();
|
|
357
416
|
const state = (extra = {}) => { try {
|
|
358
|
-
writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, host: j.host, cap: j.cap, at, ...extra }));
|
|
417
|
+
writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, model: argvModel(j.argv), host: j.host, cap: j.cap, at, ...extra }));
|
|
359
418
|
}
|
|
360
419
|
catch { /* best effort */ } };
|
|
361
420
|
state();
|
|
@@ -408,7 +467,7 @@ async function runJob(file) {
|
|
|
408
467
|
if (!existsSync(`${O}.done`)) {
|
|
409
468
|
writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
410
469
|
if (j.parent)
|
|
411
|
-
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md
|
|
470
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
|
|
412
471
|
}
|
|
413
472
|
rmSync(`${O}.pid`, { force: true });
|
|
414
473
|
return ok ? "ok" : "fail";
|
|
@@ -500,12 +559,26 @@ async function runJob(file) {
|
|
|
500
559
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
501
560
|
if (rc === 0 && res.rc)
|
|
502
561
|
rc = res.rc;
|
|
562
|
+
// A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
|
|
563
|
+
let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
|
|
564
|
+
if (j.filter && verdict) {
|
|
565
|
+
const f = spawnSync("sh", ["-c", j.filter], { cwd: j.dir, input: readFileSync(`${O}.out.md`, "utf8"), encoding: "utf8", maxBuffer: 1 << 30 });
|
|
566
|
+
if (f.status === 0)
|
|
567
|
+
writeFileSync(`${O}.out.md`, f.stdout);
|
|
568
|
+
else {
|
|
569
|
+
appendFileSync(`${O}.log`, `[orq agent] filter failed (exit ${f.status}): ${f.stderr}\n`);
|
|
570
|
+
verdict = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
503
573
|
let answer = "";
|
|
504
574
|
try {
|
|
505
575
|
answer = readFileSync(`${O}.out.md`, "utf8");
|
|
506
576
|
}
|
|
507
577
|
catch { /* none */ }
|
|
508
|
-
|
|
578
|
+
// A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
|
|
579
|
+
// fallback and the conductor must see it as not run, same as a whitespace-only answer.
|
|
580
|
+
const noReview = noReviewMatch(j.noReview, answer);
|
|
581
|
+
const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
|
|
509
582
|
writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
|
|
510
583
|
rmSync(`${O}.pid`, { force: true });
|
|
511
584
|
state({ status: st, rc, end: new Date().toISOString() });
|
package/dist/commands/block.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// orq block — takes an agent (or account) out of orq-pick until a date. For quotas without a collector
|
|
2
2
|
// (Antigravity, Copilot, MiniMax) or any outage you know about before the agent fails.
|
|
3
|
-
// State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason}}), shared with orq-pick.
|
|
3
|
+
// State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason, acct}}), shared with orq-pick. `acct` is the
|
|
4
|
+
// login at block time (src/identity.ts): if the agent/account is logged into another one later, the block is dropped.
|
|
4
5
|
import { readFileSync } from "node:fs";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import { writeAtomic } from "../fsutil.js";
|
|
8
|
+
import { loginId } from "../identity.js";
|
|
7
9
|
import { m } from "../i18n.js";
|
|
8
10
|
import { stateDir } from "../paths.js";
|
|
9
|
-
|
|
11
|
+
import { getPath, loadConfig } from "../config.js";
|
|
12
|
+
import { collect } from "../quota.js";
|
|
13
|
+
export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N | --quota] [--reason "text"] default: 7 days; --quota: until the quota window resets
|
|
10
14
|
orq block -r <agent|account> unblock early
|
|
11
15
|
orq block -l list active blocks (expired ones disappear by themselves)`;
|
|
12
16
|
const file = () => join(stateDir(), "blocks.json");
|
|
@@ -19,7 +23,21 @@ export function load() {
|
|
|
19
23
|
b = JSON.parse(readFileSync(file(), "utf8"));
|
|
20
24
|
}
|
|
21
25
|
catch { /* missing or broken: no blocks */ }
|
|
22
|
-
|
|
26
|
+
const live = Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
|
|
27
|
+
// A block made under another login (the account was switched) no longer applies: the new one has its own quota.
|
|
28
|
+
const kept = Object.fromEntries(Object.entries(live).filter(([k, v]) => {
|
|
29
|
+
if (!v.acct)
|
|
30
|
+
return true;
|
|
31
|
+
const cur = loginId(k);
|
|
32
|
+
return cur === undefined || cur === v.acct;
|
|
33
|
+
}));
|
|
34
|
+
if (Object.keys(kept).length !== Object.keys(live).length) {
|
|
35
|
+
try {
|
|
36
|
+
save(kept);
|
|
37
|
+
}
|
|
38
|
+
catch { /* best effort */ }
|
|
39
|
+
}
|
|
40
|
+
return kept;
|
|
23
41
|
}
|
|
24
42
|
const save = (b) => writeAtomic(file(), JSON.stringify(b, null, 2));
|
|
25
43
|
/** "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM" in local time (a bare date in JS would be UTC). */
|
|
@@ -29,7 +47,16 @@ export function parseLocal(s) {
|
|
|
29
47
|
throw new Error(m(`invalid date: ${s}`, `data inválida: ${s}`));
|
|
30
48
|
return d;
|
|
31
49
|
}
|
|
32
|
-
|
|
50
|
+
/** When a quota line's exhausted window resets, in epoch seconds: the week if it is used up, else the 5h window (the
|
|
51
|
+
* reading can lag behind the limit: Codex's comes from its last session). undefined = no reset known. */
|
|
52
|
+
export function quotaUntil(line) {
|
|
53
|
+
const reset = (k) => { const x = new RegExp(`\\b${k}=(\\d+)`).exec(line); return x ? Number(x[1]) : undefined; };
|
|
54
|
+
const week = /\bweek=(\d+)%/.exec(line);
|
|
55
|
+
if (week && Number(week[1]) >= 100 && reset("resetweek"))
|
|
56
|
+
return reset("resetweek");
|
|
57
|
+
return reset("reset5h") ?? reset("resetweek");
|
|
58
|
+
}
|
|
59
|
+
export async function block(argv) {
|
|
33
60
|
const b = load();
|
|
34
61
|
if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
|
|
35
62
|
console.log(USAGE);
|
|
@@ -53,7 +80,7 @@ export function block(argv) {
|
|
|
53
80
|
return 0;
|
|
54
81
|
}
|
|
55
82
|
const [name, ...rest] = argv;
|
|
56
|
-
let until = new Date(Date.now() + 7 * 86400_000), reason = "";
|
|
83
|
+
let until = new Date(Date.now() + 7 * 86400_000), reason = "", byQuota = false;
|
|
57
84
|
while (rest.length) {
|
|
58
85
|
const o = rest.shift();
|
|
59
86
|
if (o === "--until")
|
|
@@ -62,12 +89,25 @@ export function block(argv) {
|
|
|
62
89
|
until = new Date(Date.now() + Number(rest.shift()) * 86400_000);
|
|
63
90
|
else if (o === "--reason")
|
|
64
91
|
reason = rest.shift() ?? "";
|
|
92
|
+
else if (o === "--quota")
|
|
93
|
+
byQuota = true;
|
|
94
|
+
}
|
|
95
|
+
if (byQuota) {
|
|
96
|
+
const sources = (getPath(loadConfig().cfg, "quota") ?? {});
|
|
97
|
+
const line = (await collect(sources, [name])).find((l) => l.startsWith(`${name} `));
|
|
98
|
+
const r = line && quotaUntil(line);
|
|
99
|
+
if (!r) {
|
|
100
|
+
console.error(m(`${name}: no quota reset known; use --until or --days`, `${name}: sem horário de renovação da cota; use --until ou --days`));
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
until = new Date(r * 1000);
|
|
65
104
|
}
|
|
66
105
|
if (Number.isNaN(until.getTime())) {
|
|
67
106
|
console.error(USAGE);
|
|
68
107
|
return 2;
|
|
69
108
|
}
|
|
70
|
-
|
|
109
|
+
const acct = loginId(name);
|
|
110
|
+
b[name] = { until: until.getTime() / 1000, reason, ...(acct ? { acct } : {}) };
|
|
71
111
|
save(b);
|
|
72
112
|
console.log(`${name} ${m("out of orq-pick until", "fora do orq-pick até")} ${fmt(until)}`);
|
|
73
113
|
return 0;
|
package/dist/commands/board.js
CHANGED
|
@@ -7,20 +7,25 @@
|
|
|
7
7
|
// Refreshes every --interval s on a TTY; otherwise (or with --once) prints once; --json for scripts.
|
|
8
8
|
// Keys: q quits · r refreshes the quota · l language · t theme (ui.theme: gunmetal | chrome | lcd | terminal).
|
|
9
9
|
import { execFile, spawn } from "node:child_process";
|
|
10
|
-
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
|
|
10
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
|
|
11
11
|
import { freemem, homedir, totalmem } from "node:os";
|
|
12
12
|
import { basename, dirname, join, resolve } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { colorMode } from "../banner.js";
|
|
15
|
+
import { collectCi, collectedAt, loadCache as loadCiCache } from "../ci.js";
|
|
15
16
|
import { getPath, loadConfig } from "../config.js";
|
|
16
17
|
import { writeAtomic } from "../fsutil.js";
|
|
17
18
|
import { m, setLang } from "../i18n.js";
|
|
19
|
+
import { argvModel } from "../agents.js";
|
|
18
20
|
import { alive, status as lockStatus } from "../lock.js";
|
|
19
21
|
import { expand, stateDir } from "../paths.js";
|
|
20
22
|
import { processTableAsync, treeMem } from "../process.js";
|
|
21
23
|
import { collect } from "../quota.js";
|
|
24
|
+
import { collectRounds, mark, roundsSig } from "../rounds.js";
|
|
22
25
|
import { collectRuns, rows } from "../stats.js";
|
|
26
|
+
import { pendingNotices, readLog } from "../notice.js";
|
|
23
27
|
import { load as loadBlocks } from "./block.js";
|
|
28
|
+
import { herdrNamesAsync, nameOf } from "./claim.js";
|
|
24
29
|
import { setValue } from "./set.js";
|
|
25
30
|
export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
|
|
26
31
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
@@ -32,19 +37,20 @@ catch {
|
|
|
32
37
|
return "";
|
|
33
38
|
} };
|
|
34
39
|
const models = new Map();
|
|
35
|
-
/** The model a run
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
/** The model of a run recorded before its state file had one, from its job file; a rerun on the same prefix
|
|
41
|
+
* overwrites that file, so another unit's job says nothing about this run. */
|
|
42
|
+
function modelOf(prefix, unit) {
|
|
43
|
+
if (!models.has(unit)) {
|
|
38
44
|
let v;
|
|
39
45
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
const j = JSON.parse(read(`${prefix}.job.json`));
|
|
47
|
+
if (!j.unit || j.unit === unit)
|
|
48
|
+
v = argvModel(j.argv ?? []);
|
|
43
49
|
}
|
|
44
50
|
catch { /* no job file */ }
|
|
45
|
-
models.set(
|
|
51
|
+
models.set(unit, v);
|
|
46
52
|
}
|
|
47
|
-
return models.get(
|
|
53
|
+
return models.get(unit);
|
|
48
54
|
}
|
|
49
55
|
/** What the agent did last, from the tail of its log (stream JSON of claude/opencode, else the last text line). */
|
|
50
56
|
export function activity(log) {
|
|
@@ -107,7 +113,7 @@ function agents(table, now) {
|
|
|
107
113
|
continue;
|
|
108
114
|
if ((st === "ok" || st === "fail") && now - (end ?? at) > RECENT_MS)
|
|
109
115
|
continue;
|
|
110
|
-
const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: modelOf(prefix),
|
|
116
|
+
const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: typeof s.model === "string" ? s.model : modelOf(prefix, f.slice(0, -5)),
|
|
111
117
|
host: s.host, pid, issue: basename(dirname(prefix)), label: basename(prefix), state: st,
|
|
112
118
|
secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
|
|
113
119
|
if (s.rc !== undefined)
|
|
@@ -124,16 +130,40 @@ function agents(table, now) {
|
|
|
124
130
|
}
|
|
125
131
|
const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
|
|
126
132
|
const herdrAlive = (id) => new Promise((res) => execFile("herdr", ["agent", "get", id], { timeout: 5000, windowsHide: true }, (e) => res(!e)));
|
|
133
|
+
// The board redraws every few seconds: reread an issue's reviews only when its folder changed.
|
|
134
|
+
const roundsCache = new Map();
|
|
135
|
+
function cachedRounds(ws, id, max) {
|
|
136
|
+
const key = `${ws}\0${id}\0${max}`, sig = roundsSig(ws, id), hit = roundsCache.get(key);
|
|
137
|
+
if (hit && hit.sig === sig)
|
|
138
|
+
return hit.x;
|
|
139
|
+
const x = collectRounds(ws, id, max);
|
|
140
|
+
roundsCache.set(key, { sig, x });
|
|
141
|
+
return x;
|
|
142
|
+
}
|
|
127
143
|
async function conductors(wss) {
|
|
128
144
|
const by = new Map();
|
|
129
145
|
for (const ws of wss) {
|
|
130
146
|
const O = join(ws, ".orquestra");
|
|
131
|
-
|
|
147
|
+
const max = Number((() => { try {
|
|
148
|
+
return getPath(loadConfig(ws).cfg, "policy.max_rounds");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
} })() ?? 2);
|
|
153
|
+
for (const id of existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []) {
|
|
132
154
|
const t = read(join(O, id, "owner")).trim();
|
|
133
155
|
if (!t)
|
|
134
156
|
continue;
|
|
135
157
|
const who = field(t, "pane"), at = Date.parse(field(t, "at")) || 0;
|
|
136
|
-
const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at };
|
|
158
|
+
const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at, name: nameOf(t) };
|
|
159
|
+
if (!c.name)
|
|
160
|
+
c.name = nameOf(t);
|
|
161
|
+
try {
|
|
162
|
+
const x = cachedRounds(ws, id, max);
|
|
163
|
+
if (x)
|
|
164
|
+
(c.rounds ??= {})[id] = { rounds: x.rounds, max: x.max, level: x.level };
|
|
165
|
+
}
|
|
166
|
+
catch { /* unreadable issue folder: no marker */ }
|
|
137
167
|
c.issues.push(id);
|
|
138
168
|
c.at = Math.max(c.at, at);
|
|
139
169
|
by.set(who, c);
|
|
@@ -141,7 +171,15 @@ async function conductors(wss) {
|
|
|
141
171
|
}
|
|
142
172
|
const ttl = Number(process.env.ORQ_CLAIM_TTL_H ?? 24) * 3600_000;
|
|
143
173
|
const out = [...by.values()];
|
|
144
|
-
|
|
174
|
+
// One `herdr workspace list` per refresh, shared by every conductor (not 2 spawns each), off the event
|
|
175
|
+
// loop (execFile, not spawnSync) so a slow/busy herdr daemon delays only this redraw, never key handling.
|
|
176
|
+
const names = out.some((c) => c.kind === "herdr") ? await herdrNamesAsync(out.filter((c) => c.kind === "herdr").map((c) => c.id)) : new Map();
|
|
177
|
+
await Promise.all(out.map(async (c) => {
|
|
178
|
+
c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl;
|
|
179
|
+
// Alive herdr conductor: the workspace's current label (the user may have renamed it since the claim).
|
|
180
|
+
if (c.alive && c.kind === "herdr")
|
|
181
|
+
c.name = names.get(c.id) || c.name;
|
|
182
|
+
}));
|
|
145
183
|
return out.filter((c) => c.alive).map(({ at: _, ...c }) => ({ ...c, issues: c.issues.sort() }));
|
|
146
184
|
}
|
|
147
185
|
// Quota: the board never waits for the network. A stale cache starts `orq board --collect-quota` detached.
|
|
@@ -149,8 +187,9 @@ const qdir = () => join(stateDir(), "board");
|
|
|
149
187
|
const qfile = () => join(qdir(), "quota.txt");
|
|
150
188
|
export function parseQuota(text) {
|
|
151
189
|
return text.split(/\r?\n/).flatMap((l) => {
|
|
152
|
-
const r = /^(\S+) 5h=(\S+) week=(\S+)(?: \[(.*)\])?/.exec(l.trim());
|
|
153
|
-
return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? {
|
|
190
|
+
const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=\d+)?(?: spanweek=\d+)?(?: \[(.*)\])?/.exec(l.trim());
|
|
191
|
+
return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { reset5h: Number(r[4]) } : {}),
|
|
192
|
+
...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { note: r[6] } : {}) }] : [];
|
|
154
193
|
});
|
|
155
194
|
}
|
|
156
195
|
export function quota(now) {
|
|
@@ -179,6 +218,41 @@ async function collectQuota() {
|
|
|
179
218
|
writeAtomic(qfile(), lines.join("\n") + "\n");
|
|
180
219
|
return 0;
|
|
181
220
|
}
|
|
221
|
+
// CI: the same never-block-the-board rule as quota — a stale cache starts `orq board --collect-ci -w <ws>...`
|
|
222
|
+
// detached, reusing this same timer instead of a new one; the board itself only ever reads the cache file.
|
|
223
|
+
function startCollectCi(wss) {
|
|
224
|
+
const lock = join(qdir(), "ci.pid");
|
|
225
|
+
const pid = Number(read(lock));
|
|
226
|
+
if (pid > 0 && alive(pid))
|
|
227
|
+
return;
|
|
228
|
+
mkdirSync(qdir(), { recursive: true });
|
|
229
|
+
const c = spawn(process.execPath, [CLI, "board", ...wss.flatMap((w) => ["-w", w]), "--collect-ci"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
230
|
+
if (c.pid)
|
|
231
|
+
writeAtomic(lock, String(c.pid));
|
|
232
|
+
c.unref();
|
|
233
|
+
}
|
|
234
|
+
// Age per requested workspace, not the shared cache file's mtime: ci.json holds every workspace's entries and
|
|
235
|
+
// collectCi() rewrites the whole file on every run, so one workspace's collection would otherwise renew the
|
|
236
|
+
// mtime for all of them and starve a second workspace that was never actually collected. collectedAt() reads a
|
|
237
|
+
// per-workspace marker that collectCi() writes even when it finds zero entries (no claimed issue with a matching
|
|
238
|
+
// worktree) — without it, a workspace with nothing to collect would look permanently stale (entries' own `at`
|
|
239
|
+
// would never exist for it) and the board would relaunch the collector every redraw forever.
|
|
240
|
+
function ci(wss, now) {
|
|
241
|
+
const entries = loadCiCache().filter((e) => wss.includes(e.ws));
|
|
242
|
+
const ages = wss.map((w) => { const at = collectedAt(w); return at === undefined ? undefined : now - at; });
|
|
243
|
+
const age = ages.some((a) => a === undefined) ? undefined : Math.max(...ages);
|
|
244
|
+
if (age === undefined || age > QTTL)
|
|
245
|
+
startCollectCi(wss);
|
|
246
|
+
return { entries, age };
|
|
247
|
+
}
|
|
248
|
+
// Detached (stdio: "ignore"): collectCi()'s default notice (console.log) would otherwise vanish into /dev/null,
|
|
249
|
+
// so a non-herdr conductor's "CI failed" line is appended to a log file instead.
|
|
250
|
+
function collectCiAll(wss) {
|
|
251
|
+
const notice = (l) => appendFileSync(join(qdir(), "ci.log"), `${l}\n`);
|
|
252
|
+
for (const ws of wss)
|
|
253
|
+
collectCi(ws, { notice });
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
182
256
|
let sb;
|
|
183
257
|
function scoreboard(ws, now) {
|
|
184
258
|
if (!sb || sb.ws !== ws || now - sb.at > 60_000) {
|
|
@@ -197,7 +271,7 @@ export async function snapshot(wss, table) {
|
|
|
197
271
|
return {
|
|
198
272
|
at: new Date(now).toISOString(), mem: { used: totalmem() - freemem(), total: totalmem() }, workspaces: wss,
|
|
199
273
|
queue: { holders: q.holders.map(item), waiters: q.waiters.map(item) }, agents: agents(table, now),
|
|
200
|
-
conductors: await conductors(wss), quota: quota(now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
|
|
274
|
+
conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now), notices: pendingNotices(readLog(), now),
|
|
201
275
|
};
|
|
202
276
|
}
|
|
203
277
|
// --- render -----------------------------------------------------------------------------------------------------
|
|
@@ -251,6 +325,15 @@ export function render(s, theme, cols, mode = colorMode()) {
|
|
|
251
325
|
out.push(`${c.surf}${c.b}${c.acc} ORQ${c.b ? "\x1b[22m" : ""}${c.surf}${c.ink2} ${ws}${" ".repeat(Math.max(1, W - left.length - right.length))}${c.mut}${right}`);
|
|
252
326
|
const mp = Math.round(100 * s.mem.used / s.mem.total);
|
|
253
327
|
out.push(` ${c.mut}MEM${c.r} ${meter(mp, 24)} ${c.ink}${gb(s.mem.used)}${c.r} ${c.mut}/ ${gb(s.mem.total)}${c.r}`);
|
|
328
|
+
// Red line(s) first: a failure the conductor may not know about is what a person must see before anything else.
|
|
329
|
+
if (s.notices.length) {
|
|
330
|
+
const ago = (at) => dur(Math.max(0, Math.round((new Date(s.at).getTime() - at) / 1000)));
|
|
331
|
+
out.push(` ${c.err}${c.b}✗ ${m(`${s.notices.length} failure(s) the conductor may not know about`, `${s.notices.length} falha(s) que o conductor pode não saber`)}${c.r}`);
|
|
332
|
+
for (const n of s.notices.slice(0, 5)) {
|
|
333
|
+
const what = n.line.replace(/^\[[^\]]+\] /, "").replace(/ answer: .*/, "");
|
|
334
|
+
out.push(` ${c.err} ${fit(n.issue ?? "-", 10)}${c.r} ${c.ink}${fit(what, Math.max(20, W - 44))}${c.r} ${c.err}${n.why === "undelivered" ? m("not delivered", "não entregue") : m("not acknowledged", "sem resposta")}${c.r} ${c.mut}${ago(n.at)}${m(" ago", " atrás")}${c.r}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
254
337
|
section("TRANSPORT", m("test queue", "fila de testes"));
|
|
255
338
|
for (const h of s.queue.holders)
|
|
256
339
|
out.push(` ${c.acc}▶ REC${c.r} ${c.ink}${fit(h.label, 34)}${c.r} ${c.ok}${m("running", "rodando")}${c.r} ${c.ink}${dur(h.secs).padEnd(7)}${c.r} ${c.ink2}${gb(h.mem)}${c.r}`
|
|
@@ -261,7 +344,7 @@ export function render(s, theme, cols, mode = colorMode()) {
|
|
|
261
344
|
section("TRACKS", m("agents", "agentes"));
|
|
262
345
|
s.agents.forEach((a, i) => {
|
|
263
346
|
const n = `${c.mut}${String(i + 1).padStart(2, "0")}${c.r}`, who = `${c.ink}${fit(a.issue, 9)}${c.r} ${c.ink2}${fit(a.label, 22)}${c.r} `
|
|
264
|
-
+ `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${dur(a.secs).padEnd(7)}${c.r}`;
|
|
347
|
+
+ `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${(a.state === "died" ? "?" : dur(a.secs)).padEnd(7)}${c.r}`; // died: when it stopped is unknown
|
|
265
348
|
if (a.state === "running") {
|
|
266
349
|
const pct = a.cap && a.mem ? Math.round(100 * a.mem / a.cap) : 0, used = 1 + 2 + 2 + 10 + 23 + 11 + 17 + 1 + 8 + 6 + 1 + 7 + 1;
|
|
267
350
|
const warn = a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
|
|
@@ -276,9 +359,15 @@ export function render(s, theme, cols, mode = colorMode()) {
|
|
|
276
359
|
out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
|
|
277
360
|
section("MASTER", m("conductors", "conductors"));
|
|
278
361
|
for (const k of s.conductors)
|
|
279
|
-
out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.join("
|
|
362
|
+
out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.name || k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.map((i) => (k.rounds?.[i] ? `${i} ${mark(k.rounds[i])}` : i)).join(" ")}${c.r}`);
|
|
280
363
|
if (!s.conductors.length)
|
|
281
364
|
out.push(` ${c.mut}${m("no claimed issues", "nenhuma issue reivindicada")}${c.r}`);
|
|
365
|
+
section("MONITOR", m("CI/CD", "CI/CD"));
|
|
366
|
+
const ciMark = { passed: `${c.ok}✓`, failed: `${c.err}✗`, running: `${c.acc}●`, queued: `${c.mut}‖`, none: `${c.mut}·` };
|
|
367
|
+
for (const e of s.ci.entries)
|
|
368
|
+
out.push(` ${ciMark[e.state]}${c.r} ${c.ink}${fit(e.issue, 10)}${c.r} ${c.ink2}${fit(e.repo, 14)}${c.r} ${c.mut}${fit(e.branch, 24)}${c.r} ${c.ink}${(e.pr ? `#${e.pr}` : "-").padEnd(6)}${c.r} ${c.ink2}${e.state}${c.r}`);
|
|
369
|
+
if (!s.ci.entries.length)
|
|
370
|
+
out.push(` ${c.mut}${s.ci.age === undefined ? m("collecting...", "coletando...") : m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR")}${c.r}`);
|
|
282
371
|
section("MIXER", m("quota", "cota"));
|
|
283
372
|
const win = (v, tag) => {
|
|
284
373
|
const n = /^(\d+)%?$/.exec(v)?.[1];
|
|
@@ -329,6 +418,8 @@ export async function board(argv) {
|
|
|
329
418
|
interval = Math.max(1, Number(argv[++i]) || 3);
|
|
330
419
|
else if (a === "--collect-quota")
|
|
331
420
|
return collectQuota();
|
|
421
|
+
else if (a === "--collect-ci")
|
|
422
|
+
return collectCiAll(wss);
|
|
332
423
|
else if (a === "-h" || a === "--help") {
|
|
333
424
|
console.log(USAGE);
|
|
334
425
|
return 0;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// orq ci [-w <workspace>]... [--json] CI/CD signal per claimed issue with a PR: queued | running | passed |
|
|
2
|
+
// failed | none. This is the one caller of src/ci.ts's collectCi() that runs it in the foreground (network and
|
|
3
|
+
// all) instead of reading its cache — the user asked for CI status right now, so it collects right now; `orq
|
|
4
|
+
// board`'s CI column is the one that stays off the network and only reads the cache this same collectCi() fills.
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { collectCi } from "../ci.js";
|
|
7
|
+
import { getPath, loadConfig } from "../config.js";
|
|
8
|
+
import { m } from "../i18n.js";
|
|
9
|
+
import { expand } from "../paths.js";
|
|
10
|
+
export const USAGE = "orq ci [-w <workspace>]... [--json] CI/CD status per claimed issue (queued|running|passed|failed|none)";
|
|
11
|
+
const label = (s) => ({ queued: m("queued", "na fila"), running: m("running", "rodando"),
|
|
12
|
+
passed: m("passed", "passou"), failed: m("failed", "falhou"), none: m("none", "nenhum") })[s];
|
|
13
|
+
export async function ci(argv) {
|
|
14
|
+
const wss = [];
|
|
15
|
+
let json = false;
|
|
16
|
+
for (let i = 0; i < argv.length; i++) {
|
|
17
|
+
const a = argv[i];
|
|
18
|
+
if (a === "-w")
|
|
19
|
+
wss.push(resolve(expand(argv[++i] ?? ".")));
|
|
20
|
+
else if (a === "--json")
|
|
21
|
+
json = true;
|
|
22
|
+
else if (a === "-h" || a === "--help") {
|
|
23
|
+
console.log(USAGE);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error(USAGE);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!wss.length)
|
|
32
|
+
wss.push(getPath(loadConfig().cfg, "_workspace") ?? process.cwd());
|
|
33
|
+
// --json's stdout must be only the JSON: a non-herdr owner's notice goes to stderr instead of the default
|
|
34
|
+
// stdout, which would otherwise interleave a stray line in front of the JSON.
|
|
35
|
+
const hooks = json ? { notice: (line) => console.error(line) } : {};
|
|
36
|
+
const entries = wss.flatMap((ws) => collectCi(ws, hooks));
|
|
37
|
+
if (json) {
|
|
38
|
+
console.log(JSON.stringify(entries));
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (!entries.length) {
|
|
42
|
+
console.log(m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR"));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries)
|
|
46
|
+
console.log(`${e.issue} ${e.repo} ${e.branch} ${e.pr ? `#${e.pr}` : "-"} ${label(e.state)}`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|