@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.20
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/CHANGELOG.md +163 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +74 -12
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +8 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +37 -49
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
package/build/cli.js
CHANGED
|
@@ -5,29 +5,93 @@ import { resolve, sep } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
+
import { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
|
|
8
9
|
import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
|
|
9
|
-
import {
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
|
|
12
|
+
import { logCrashes, startDaemon } from "./daemon.js";
|
|
10
13
|
import { ConductorError } from "./errors.js";
|
|
14
|
+
import { eventsCommand } from "./events.js";
|
|
15
|
+
import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown } from "./history.js";
|
|
16
|
+
import { inboxLines } from "./inbox.js";
|
|
17
|
+
import { parseJsonc } from "./jsonc.js";
|
|
11
18
|
import { processAlive, readPid } from "./lock.js";
|
|
12
19
|
import { fleetWorkerSchema } from "./manager.js";
|
|
20
|
+
import { installMod, removeMod } from "./mod.js";
|
|
21
|
+
import { noteCommand } from "./note.js";
|
|
22
|
+
import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
|
|
23
|
+
import { overrideViewSchema, PAUSE } from "./overrides.js";
|
|
13
24
|
import { conductorPaths } from "./paths.js";
|
|
14
25
|
import { registerProject } from "./registry.js";
|
|
26
|
+
import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
|
|
27
|
+
import { resolveUntil } from "./rules.js";
|
|
15
28
|
import { outputOf, run } from "./run.js";
|
|
29
|
+
import { queueLines, queueViewSchema } from "./scheduler.js";
|
|
30
|
+
import { formatTokens } from "./spend.js";
|
|
16
31
|
import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
|
|
17
|
-
import { openStore, projectSchema, workerSchema } from "./store.js";
|
|
18
|
-
import { parseTag, upgrade } from "./upgrade.js";
|
|
32
|
+
import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
|
|
33
|
+
import { parseTag, restartDaemon, upgrade } from "./upgrade.js";
|
|
19
34
|
import { waitCommand } from "./wait.js";
|
|
35
|
+
import { whyLines, whySchema } from "./why.js";
|
|
20
36
|
const USAGE = `Usage: conduct <command>
|
|
21
37
|
|
|
22
38
|
serve run the daemon in this process (one per machine)
|
|
39
|
+
start start the daemon detached (the logon task, else a detached serve) and
|
|
40
|
+
wait until it answers; nothing when it runs already
|
|
23
41
|
stop stop the running daemon
|
|
24
|
-
status [--json] the daemon's pid, port, projects and
|
|
42
|
+
status [--json] the daemon's pid, port, projects, workers and held locks
|
|
25
43
|
project add <path> register the project whose checkout holds .claude/conductor.json
|
|
26
44
|
project list [--json] the registered projects
|
|
27
45
|
launch <item> start a worker: its worktree, a background session, a tab
|
|
28
46
|
[--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
|
|
29
47
|
retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
|
|
30
48
|
attach <worker> wake a parked worker (no event text) and open it in a tab
|
|
49
|
+
inbox [--json] what waits on you: each open item with its commands and links
|
|
50
|
+
approve <item> [--text <text>]
|
|
51
|
+
approve a plan: post "approve" on the item as you, wake its worker;
|
|
52
|
+
<item> is ATK-312, or the inbox item's id as #12
|
|
53
|
+
revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
|
|
54
|
+
done <id> mark an inbox item done (a physical action, or one you handled)
|
|
55
|
+
notify [--json] the notification profile, quiet hours, and each channel's state
|
|
56
|
+
notify profile [<name>] [--until <when>]
|
|
57
|
+
switch the profile now (built in: default, away, focus); --until 17:00 or
|
|
58
|
+
a date and time hands back to the one before it then
|
|
59
|
+
notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
|
|
60
|
+
push a sample item (default npm-otp) on the profile's channels now;
|
|
61
|
+
--raise puts a real item in the inbox instead (quiet hours, reminders)
|
|
62
|
+
queue [--json] [--fresh] what the scheduler picks next and why the rest wait (--fresh: read now)
|
|
63
|
+
pause [<project>] [--until <when>]
|
|
64
|
+
no new launches in the project (every project without one), until
|
|
65
|
+
17:00, tomorrow, monday or a date and time; unpause ends it
|
|
66
|
+
unpause [<project>] launch again
|
|
67
|
+
focus <project> [--slots <n>] [--weight <n>] --until <when>
|
|
68
|
+
the project's slots or ordering weight, for a while; with neither,
|
|
69
|
+
its items go first (weight 100)
|
|
70
|
+
why <event id | item> [--json]
|
|
71
|
+
the rule behind an action, and the events that led to it
|
|
72
|
+
console [--project <name>] [--here]
|
|
73
|
+
open the console (the Claude session the conductor mod serves) in a
|
|
74
|
+
Windows Terminal tab in the project's main checkout; --here runs it in
|
|
75
|
+
this terminal. Also how to get it back after it closed
|
|
76
|
+
escalations [--json] what rules escalated to the console and it hasn't handled (14 days)
|
|
77
|
+
escalations done <id> [--ask] <text>
|
|
78
|
+
the console's finding on escalation #<id>, logged; --ask also puts it
|
|
79
|
+
in the inbox for you (a "console" item)
|
|
80
|
+
history [<range>] [--since <when>] [--until <when>] [--project <name>] [--json]
|
|
81
|
+
what a window of the log says: shipped items, time in each phase by tier,
|
|
82
|
+
plan latency, waits by kind, stalls, failures, inbox, slot use, rules, spend
|
|
83
|
+
retro [<range>] [--project <name>]
|
|
84
|
+
the same as a Markdown report. A <range> is today, yesterday, a date
|
|
85
|
+
(2026-10-10), A..B (2026-10-10..2026-10-11), or a span (24h, the default; 7d)
|
|
86
|
+
rules [--json] the rules in force (project, machine, built-in defaults), what replaces
|
|
87
|
+
what, and any rules file refused; machine rules are ~/.conductor/rules.json
|
|
88
|
+
rules test <file> [--json]
|
|
89
|
+
dry-run the rules on a sample, a JSON file such as
|
|
90
|
+
{ "event": "pr.comment", "project": "atk", "fields": { "wait.kind": "merge" } }
|
|
91
|
+
(no event: a state rule's subject); acts on nothing
|
|
92
|
+
note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
|
|
93
|
+
record its phase on the worker (this checkout's, or --worker <worker>);
|
|
94
|
+
exit 3 when nobody posted it (no conductor, or the tracker refused it)
|
|
31
95
|
wait <condition> wait for an outside event: in a worker's worktree with the daemon
|
|
32
96
|
running, register it and end the turn (the daemon resumes the session);
|
|
33
97
|
otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
|
|
@@ -37,13 +101,32 @@ const USAGE = `Usage: conduct <command>
|
|
|
37
101
|
health <env> --version <x> | --commit <sha>
|
|
38
102
|
approval [<item>] an approve / revise: comment after the item's newest Plan note
|
|
39
103
|
item <item> <state> | item <item> label|note <value>
|
|
40
|
-
time <when> such as 2026-10-10T09:00 (local)
|
|
104
|
+
time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
|
|
41
105
|
<full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
|
|
42
106
|
[--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
|
|
107
|
+
ports [<path>] [--prefer <slot>] [--json]
|
|
108
|
+
the checkout's dev ports from the port broker: its project's base ports
|
|
109
|
+
+ its slot, which no other project's worktree shares (a main checkout:
|
|
110
|
+
slot 0); --prefer asks for a slot first. Without <path>, every worktree's.
|
|
111
|
+
Exit 3 when no conductor brokered it, so the caller keeps its own ports
|
|
112
|
+
lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
|
|
113
|
+
a lock every project shares, such as chrome for verify runs; stale 30 min
|
|
114
|
+
after its holder last acquired it (acquire again to keep it). The owner is
|
|
115
|
+
this checkout's worker's item unless given; --wait polls. Prints JSON;
|
|
116
|
+
exit 3 when no conductor answers
|
|
117
|
+
events [--since <id>] the event log after <id> (all of it without), one JSON event per line
|
|
118
|
+
events --follow [--since <id>]
|
|
119
|
+
keep following it (live without --since), reconnecting when the
|
|
120
|
+
conductor restarts; state lines conduct.connected / conduct.disconnected
|
|
43
121
|
install-startup start the daemon at logon (Windows Task Scheduler)
|
|
44
122
|
install-startup --remove remove that logon task
|
|
123
|
+
install-mod install the conductor mod in Claude Code (user scope) from this package:
|
|
124
|
+
the status line in every session, the inbox band, pane and commands in
|
|
125
|
+
the console (a session started with CONDUCTOR_CONSOLE=1)
|
|
126
|
+
install-mod --remove uninstall it
|
|
45
127
|
upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
|
|
46
128
|
the tag the installed version came from), start the daemon again
|
|
129
|
+
[--allow-downgrade] install the tag's version even when it is older than the installed one
|
|
47
130
|
|
|
48
131
|
A <worker> is its item (ATK-309) or its name (probe-ATK-309).
|
|
49
132
|
Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
|
|
@@ -58,6 +141,8 @@ const LAUNCH_WAIT_MS = 15 * 60_000;
|
|
|
58
141
|
// attach waits out the settle time (up to 5 min), resumes (120 s) and opens the tab (60 s).
|
|
59
142
|
const RETIRE_TIMEOUT_MS = 12 * 60_000;
|
|
60
143
|
const ATTACH_TIMEOUT_MS = 9 * 60_000;
|
|
144
|
+
// Approve and revise ask Linear twice (the issue, then the comment), each within 20 s.
|
|
145
|
+
const ANSWER_TIMEOUT_MS = 60_000;
|
|
61
146
|
const projectsBody = z.object({ projects: z.array(projectSchema) });
|
|
62
147
|
const addBody = z.object({ created: z.boolean(), project: projectSchema });
|
|
63
148
|
const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
|
|
@@ -77,6 +162,13 @@ const retireBody = z.object({
|
|
|
77
162
|
worker: workerSchema,
|
|
78
163
|
});
|
|
79
164
|
const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
|
|
165
|
+
const inboxBody = z.object({ items: z.array(inboxItemSchema) });
|
|
166
|
+
const answerBody = z.object({
|
|
167
|
+
comment: z.object({ id: z.string(), url: z.string().nullable() }).nullable(),
|
|
168
|
+
item: inboxItemSchema,
|
|
169
|
+
waits: z.array(waitSchema),
|
|
170
|
+
});
|
|
171
|
+
const answerRefusal = z.object({ details: z.array(z.string()).optional(), error: z.string(), posted: z.enum(['no', 'unknown']).optional() });
|
|
80
172
|
async function addProject(paths, path) {
|
|
81
173
|
const absolute = resolve(path);
|
|
82
174
|
let result;
|
|
@@ -87,10 +179,38 @@ async function addProject(paths, path) {
|
|
|
87
179
|
if (!(error instanceof NotRunningError))
|
|
88
180
|
throw error;
|
|
89
181
|
// No daemon: write the store directly. The daemon reads it when it starts.
|
|
90
|
-
|
|
182
|
+
// Its rules are checked against config.json here too, as the daemon would.
|
|
183
|
+
const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
|
|
184
|
+
result = withStore(paths, (store) => registerProject(store, absolute, check));
|
|
91
185
|
}
|
|
92
186
|
out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
|
|
93
187
|
}
|
|
188
|
+
// `conduct approve|revise|done`: Jason's answer to an inbox item, by id, or for approve and revise
|
|
189
|
+
// by the item whose plan waits.
|
|
190
|
+
async function answer(paths, ref, request) {
|
|
191
|
+
const target = request.action === 'done' ? inboxId(ref) : await planItem(paths, ref);
|
|
192
|
+
const { body, status } = await callApi(paths, { body: request, method: 'POST', path: `/inbox/${target}/resolve`, timeoutMs: ANSWER_TIMEOUT_MS });
|
|
193
|
+
if (status !== 200) {
|
|
194
|
+
const refusal = answerRefusal.safeParse(body).data;
|
|
195
|
+
const details = [...(refusal?.details ?? [])];
|
|
196
|
+
if (status === 502 && refusal?.posted === 'unknown')
|
|
197
|
+
details.push('the comment may be on the item already: look before answering again');
|
|
198
|
+
if (status === 404) {
|
|
199
|
+
if ((await openItems(paths)) === null)
|
|
200
|
+
throw noInbox();
|
|
201
|
+
throw new ConductorError(`no inbox item #${target}`, ['`conduct inbox` lists the open ones']);
|
|
202
|
+
}
|
|
203
|
+
throw new ConductorError(refusal?.error ?? `the API answered ${status}`, details);
|
|
204
|
+
}
|
|
205
|
+
const result = answerBody.parse(body);
|
|
206
|
+
const { item } = result;
|
|
207
|
+
if (request.action === 'done')
|
|
208
|
+
return out(`done: #${item.id} (${item.kind}${item.item ? ` ${item.item}` : ''})`);
|
|
209
|
+
out(`${request.action === 'approve' ? 'approved' : 'asked for a revision of'} ${item.item ?? `#${item.id}`}: ${result.comment?.url ?? result.comment?.id ?? 'comment posted'}`);
|
|
210
|
+
out(result.waits.length > 0
|
|
211
|
+
? `its worker's approval wait resolved; the conductor resumes it`
|
|
212
|
+
: 'no worker waits on its approval here: the worker reads the comment when it next looks');
|
|
213
|
+
}
|
|
94
214
|
async function attach(paths, ref) {
|
|
95
215
|
const worker = await findWorker(paths, ref);
|
|
96
216
|
const result = attachBody.parse(await expectOk(callApi(paths, { method: 'POST', path: `/workers/${worker.id}/attach`, timeoutMs: ATTACH_TIMEOUT_MS })));
|
|
@@ -109,6 +229,83 @@ async function awaitDaemon(paths) {
|
|
|
109
229
|
}
|
|
110
230
|
throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
|
|
111
231
|
}
|
|
232
|
+
// What `conduct ports` and `conduct lock` print to and run in.
|
|
233
|
+
function brokerIo() {
|
|
234
|
+
return { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out };
|
|
235
|
+
}
|
|
236
|
+
// `conduct console`: the console (design note, section 11), the Claude session the conductor mod
|
|
237
|
+
// serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
|
|
238
|
+
// is installed. It keeps no state of its own, so a new one after a close loses nothing.
|
|
239
|
+
async function consoleCommand(paths, options) {
|
|
240
|
+
const project = consoleProject(await projectsNow(paths), options.project);
|
|
241
|
+
if (options.here) {
|
|
242
|
+
const child = spawn('claude', CONSOLE_ARGS, { cwd: project.root, env: { ...process.env, CONDUCTOR_CONSOLE: '1' }, stdio: 'inherit' });
|
|
243
|
+
// Ctrl+C belongs to the session; this process only waits for it to end.
|
|
244
|
+
process.on('SIGINT', () => { });
|
|
245
|
+
process.exitCode = await new Promise((done, fail) => {
|
|
246
|
+
child.on('error', (error) => fail(new ConductorError('could not start claude', [error.message])));
|
|
247
|
+
child.on('exit', (code) => done(code ?? 1));
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (process.platform !== 'win32') {
|
|
252
|
+
throw new ConductorError('conduct console opens a Windows Terminal tab', ['run `conduct console --here` in a terminal of your own']);
|
|
253
|
+
}
|
|
254
|
+
// A tab starts with Windows Terminal's environment, not this one's.
|
|
255
|
+
if (process.env.CONDUCTOR_HOME) {
|
|
256
|
+
throw new ConductorError('a console tab would not see CONDUCTOR_HOME', ['run `conduct console --here` in a terminal where it is set']);
|
|
257
|
+
}
|
|
258
|
+
const args = consoleTabArgs(project, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
|
|
259
|
+
const result = await run('wt.exe', args, { cwd: project.root, timeoutMs: 60_000 });
|
|
260
|
+
if (result.code !== 0)
|
|
261
|
+
throw new ConductorError('could not open the console tab', [outputOf(result, 500), 'run `conduct console --here` in a terminal instead']);
|
|
262
|
+
out(`opened the console in ${project.root} (Windows Terminal window "${CONSOLE_WINDOW}")`);
|
|
263
|
+
}
|
|
264
|
+
// How `conduct start` and upgrade's restart start the daemon detached from this process.
|
|
265
|
+
function daemonStarter(paths, cliPath) {
|
|
266
|
+
return {
|
|
267
|
+
awaitDaemon: () => awaitDaemon(paths),
|
|
268
|
+
runTask: runStartup,
|
|
269
|
+
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
270
|
+
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
271
|
+
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
272
|
+
.on('error', () => { })
|
|
273
|
+
.unref(),
|
|
274
|
+
// The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
|
|
275
|
+
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// `conduct escalations done`: the console reports back on an escalation.
|
|
279
|
+
async function escalationDone(paths, ref, finding) {
|
|
280
|
+
const id = /^#?(\d+)$/.exec(ref)?.[1];
|
|
281
|
+
if (id === undefined)
|
|
282
|
+
throw new ConductorError(`"${ref}" is not an escalation id`, ['`conduct escalations` lists them as #<id>']);
|
|
283
|
+
const answer = await callApi(paths, { body: finding, method: 'POST', path: `/console/escalations/${id}` });
|
|
284
|
+
if (answer.status === 404 && !errorBody.safeParse(answer.body).data?.details)
|
|
285
|
+
throw olderDaemon('no console escalations');
|
|
286
|
+
const { escalation, event, inbox: raised } = handledSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
287
|
+
out(`handled escalation #${escalation.id}${escalation.item ? ` on ${escalation.item}` : ''} (event #${event})`);
|
|
288
|
+
if (raised)
|
|
289
|
+
out(`and raised inbox item #${raised.id} (console) for you`);
|
|
290
|
+
}
|
|
291
|
+
async function escalations(paths, json) {
|
|
292
|
+
const answer = await callApi(paths, { method: 'GET', path: '/console/escalations' });
|
|
293
|
+
if (answer.status === 404)
|
|
294
|
+
throw olderDaemon('no console escalations');
|
|
295
|
+
const { escalations: open } = z.object({ escalations: z.array(escalationSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
296
|
+
if (json)
|
|
297
|
+
return out(JSON.stringify(open, null, 2));
|
|
298
|
+
escalationLines(open, new Date()).forEach((line) => out(line));
|
|
299
|
+
}
|
|
300
|
+
// `conduct events`: until Ctrl+C with --follow.
|
|
301
|
+
async function events(paths, follow, since) {
|
|
302
|
+
if (since !== undefined && !/^\d+$/.test(since))
|
|
303
|
+
throw new ConductorError('--since takes an event id, such as --since 120');
|
|
304
|
+
const stop = new AbortController();
|
|
305
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
|
|
306
|
+
process.once(signal, () => stop.abort());
|
|
307
|
+
await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
|
|
308
|
+
}
|
|
112
309
|
async function expectOk(call) {
|
|
113
310
|
const { body, status } = await call;
|
|
114
311
|
if (status >= 200 && status < 300)
|
|
@@ -127,13 +324,63 @@ async function findWorker(paths, ref) {
|
|
|
127
324
|
throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
|
|
128
325
|
throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
|
|
129
326
|
}
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
327
|
+
// `conduct history` and `retro`: from the daemon, or straight from conductor.db when none runs or
|
|
328
|
+
// it is older than /history. Reading needs no daemon: the report is a query over the store.
|
|
329
|
+
async function history(paths, command, range, options) {
|
|
330
|
+
const flags = options.since !== undefined || options.until !== undefined;
|
|
331
|
+
if (flags && range !== undefined)
|
|
332
|
+
throw new ConductorError(`conduct ${command} takes a <range> or --since/--until, not both`);
|
|
333
|
+
const now = new Date();
|
|
334
|
+
const window = flags ? flagWindow(options.since, options.until, now) : parseRange(range, now);
|
|
335
|
+
const request = { project: options.project ?? null, since: window.since.toISOString(), until: window.until.toISOString() };
|
|
336
|
+
// Read-only: a daemon older than this build may be running, and a migration would strand it.
|
|
337
|
+
const local = () => {
|
|
338
|
+
try {
|
|
339
|
+
return withStore(paths, (store) => historyReport(store, request), { readOnly: true });
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error instanceof ConductorError)
|
|
343
|
+
throw error;
|
|
344
|
+
throw new ConductorError(`could not read ${paths.db}`, [
|
|
345
|
+
error instanceof Error ? error.message : String(error),
|
|
346
|
+
'a schema older than this conduct, or a log left by a crash: start the conductor once (conduct serve, or conduct upgrade), then try again',
|
|
347
|
+
]);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
let report;
|
|
351
|
+
try {
|
|
352
|
+
const query = new URLSearchParams({ since: request.since, until: request.until, ...(options.project ? { project: options.project } : {}) });
|
|
353
|
+
const answer = await callApi(paths, { method: 'GET', path: `/history?${query.toString()}`, timeoutMs: 60_000 });
|
|
354
|
+
report = answer.status === 404 ? local() : historySchema.parse(await expectOk(Promise.resolve(answer)));
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (!(error instanceof NotRunningError))
|
|
358
|
+
throw error;
|
|
359
|
+
report = local();
|
|
360
|
+
}
|
|
361
|
+
if (options.json)
|
|
362
|
+
return out(JSON.stringify(report, null, 2));
|
|
363
|
+
if (command === 'retro')
|
|
364
|
+
process.stdout.write(retroMarkdown(report));
|
|
365
|
+
else
|
|
366
|
+
historyLines(report).forEach((line) => out(line));
|
|
367
|
+
}
|
|
368
|
+
async function inbox(paths, json) {
|
|
369
|
+
const items = await openItems(paths);
|
|
370
|
+
if (items === null)
|
|
371
|
+
throw noInbox();
|
|
372
|
+
if (json)
|
|
373
|
+
return out(JSON.stringify(items, null, 2));
|
|
374
|
+
if (items.length === 0)
|
|
375
|
+
return out('the inbox is empty');
|
|
376
|
+
inboxLines(items, new Date()).forEach((line) => out(line));
|
|
377
|
+
}
|
|
378
|
+
// An inbox id as `conduct inbox` prints it (#12), or bare (12).
|
|
379
|
+
function inboxId(ref) {
|
|
380
|
+
const id = /^#?(\d+)$/.exec(ref)?.[1];
|
|
381
|
+
if (id === undefined)
|
|
382
|
+
throw new ConductorError(`"${ref}" is not an inbox item id`, ['`conduct inbox` lists them as #<id>']);
|
|
383
|
+
return Number(id);
|
|
137
384
|
}
|
|
138
385
|
function install() {
|
|
139
386
|
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
@@ -167,15 +414,7 @@ async function launch(paths, item, options) {
|
|
|
167
414
|
]);
|
|
168
415
|
}
|
|
169
416
|
async function listProjects(paths, json) {
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
173
|
-
}
|
|
174
|
-
catch (error) {
|
|
175
|
-
if (!(error instanceof NotRunningError))
|
|
176
|
-
throw error;
|
|
177
|
-
projects = withStore(paths, (store) => store.listProjects());
|
|
178
|
-
}
|
|
417
|
+
const projects = await projectsNow(paths);
|
|
179
418
|
if (json)
|
|
180
419
|
return out(JSON.stringify(projects, null, 2));
|
|
181
420
|
if (projects.length === 0)
|
|
@@ -186,20 +425,54 @@ async function main(argv) {
|
|
|
186
425
|
// `wait` has options of its own: its --version is a value, not this CLI's version.
|
|
187
426
|
if (argv[0] === 'wait')
|
|
188
427
|
return wait(argv.slice(1));
|
|
428
|
+
// `revise` takes free text, which option parsing would cut at a word such as --force. Every word
|
|
429
|
+
// after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
|
|
430
|
+
if (argv[0] === 'revise') {
|
|
431
|
+
const [ref, ...words] = argv.slice(1);
|
|
432
|
+
const text = words.join(' ').trim();
|
|
433
|
+
if (ref === undefined || ref.startsWith('-') || !text)
|
|
434
|
+
throw new ConductorError('usage: conduct revise <item> "<what to change>"');
|
|
435
|
+
return answer(conductorPaths(), ref, { action: 'revise', text });
|
|
436
|
+
}
|
|
437
|
+
// The same for a finding: free text after the id, with --ask as a word of its own anywhere.
|
|
438
|
+
if (argv[0] === 'escalations' && argv[1] === 'done') {
|
|
439
|
+
const [ref, ...rest] = argv.slice(2);
|
|
440
|
+
const ask = rest.includes('--ask');
|
|
441
|
+
const text = rest.filter((word) => word !== '--ask').join(' ').trim();
|
|
442
|
+
if (ref === undefined || !text)
|
|
443
|
+
throw new ConductorError('usage: conduct escalations done <id> [--ask] "<what you found or did>"');
|
|
444
|
+
return escalationDone(conductorPaths(), ref, { ask, text });
|
|
445
|
+
}
|
|
189
446
|
const { positionals, values } = parseArgs({
|
|
190
447
|
allowPositionals: true,
|
|
191
448
|
args: argv,
|
|
192
449
|
options: {
|
|
450
|
+
'allow-downgrade': { type: 'boolean' },
|
|
451
|
+
channel: { type: 'string' },
|
|
193
452
|
conductor: { type: 'string' },
|
|
453
|
+
follow: { type: 'boolean' },
|
|
194
454
|
force: { type: 'boolean' },
|
|
455
|
+
fresh: { type: 'boolean' },
|
|
195
456
|
help: { short: 'h', type: 'boolean' },
|
|
457
|
+
here: { type: 'boolean' },
|
|
196
458
|
json: { type: 'boolean' },
|
|
459
|
+
kind: { type: 'string' },
|
|
460
|
+
owner: { type: 'string' },
|
|
461
|
+
prefer: { type: 'string' },
|
|
197
462
|
probe: { type: 'boolean' },
|
|
198
463
|
project: { type: 'string' },
|
|
199
464
|
prompt: { type: 'string' },
|
|
465
|
+
raise: { type: 'boolean' },
|
|
200
466
|
remove: { type: 'boolean' },
|
|
467
|
+
since: { type: 'string' },
|
|
468
|
+
slots: { type: 'string' },
|
|
201
469
|
tag: { type: 'string' },
|
|
470
|
+
text: { type: 'string' },
|
|
471
|
+
until: { type: 'string' },
|
|
202
472
|
version: { short: 'v', type: 'boolean' },
|
|
473
|
+
wait: { type: 'string' },
|
|
474
|
+
weight: { type: 'string' },
|
|
475
|
+
worker: { type: 'string' },
|
|
203
476
|
},
|
|
204
477
|
});
|
|
205
478
|
const paths = conductorPaths();
|
|
@@ -209,10 +482,33 @@ async function main(argv) {
|
|
|
209
482
|
if (values.help || command === undefined || command === 'help')
|
|
210
483
|
return out(USAGE);
|
|
211
484
|
switch (command) {
|
|
485
|
+
case 'approve':
|
|
486
|
+
if (sub === undefined)
|
|
487
|
+
throw new ConductorError('usage: conduct approve <item> [--text <text>]');
|
|
488
|
+
return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
|
|
212
489
|
case 'attach':
|
|
213
490
|
if (sub === undefined)
|
|
214
491
|
throw new ConductorError('usage: conduct attach <worker>');
|
|
215
492
|
return attach(paths, sub);
|
|
493
|
+
case 'console':
|
|
494
|
+
return consoleCommand(paths, { here: values.here === true, project: values.project });
|
|
495
|
+
case 'done':
|
|
496
|
+
if (sub === undefined)
|
|
497
|
+
throw new ConductorError('usage: conduct done <inbox id>');
|
|
498
|
+
return answer(paths, sub, { action: 'done' });
|
|
499
|
+
case 'escalations':
|
|
500
|
+
if (sub !== undefined)
|
|
501
|
+
throw new ConductorError('usage: conduct escalations [--json] | conduct escalations done <id> [--ask] <text>');
|
|
502
|
+
return escalations(paths, values.json === true);
|
|
503
|
+
case 'events':
|
|
504
|
+
return events(paths, values.follow === true, values.since);
|
|
505
|
+
case 'history':
|
|
506
|
+
case 'retro':
|
|
507
|
+
return history(paths, command, sub, { json: values.json === true, project: values.project, since: values.since, until: values.until });
|
|
508
|
+
case 'inbox':
|
|
509
|
+
return inbox(paths, values.json === true);
|
|
510
|
+
case 'install-mod':
|
|
511
|
+
return values.remove ? removeMod(modDeps()) : installMod(fileURLToPath(new URL('../mod', import.meta.url)), modDeps());
|
|
216
512
|
case 'install-startup':
|
|
217
513
|
return values.remove ? uninstall() : install();
|
|
218
514
|
case 'launch':
|
|
@@ -224,41 +520,216 @@ async function main(argv) {
|
|
|
224
520
|
project: values.project,
|
|
225
521
|
prompt: values.prompt,
|
|
226
522
|
});
|
|
523
|
+
case 'lock': {
|
|
524
|
+
if (sub === undefined || arg === undefined)
|
|
525
|
+
throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
|
|
526
|
+
const waitMinutes = values.wait === undefined ? undefined : Number(values.wait);
|
|
527
|
+
if (waitMinutes !== undefined && !(waitMinutes >= 0))
|
|
528
|
+
throw new ConductorError('--wait takes minutes, such as --wait 30');
|
|
529
|
+
process.exitCode = await lockCommand(paths, sub, arg, { owner: values.owner, waitMinutes }, brokerIo());
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
case 'note':
|
|
533
|
+
if (sub === undefined || arg === undefined)
|
|
534
|
+
throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
|
|
535
|
+
process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
|
|
536
|
+
cwd: process.cwd(),
|
|
537
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
538
|
+
out,
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
case 'notify':
|
|
542
|
+
if (sub === undefined)
|
|
543
|
+
return notifyStatus(paths, values.json === true);
|
|
544
|
+
if (sub === 'profile')
|
|
545
|
+
return notifyProfile(paths, arg, values.until);
|
|
546
|
+
if (sub === 'test')
|
|
547
|
+
return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
|
|
548
|
+
throw new ConductorError('usage: conduct notify | conduct notify profile [<name>] [--until <when>] | conduct notify test');
|
|
227
549
|
case 'project':
|
|
228
550
|
if (sub === 'add' && arg !== undefined)
|
|
229
551
|
return addProject(paths, arg);
|
|
230
552
|
if (sub === 'list')
|
|
231
553
|
return listProjects(paths, values.json === true);
|
|
232
554
|
throw new ConductorError('usage: conduct project add <path> | conduct project list');
|
|
555
|
+
case 'focus':
|
|
556
|
+
if (sub === undefined || values.until === undefined)
|
|
557
|
+
throw new ConductorError('usage: conduct focus <project> [--slots <n>] [--weight <n>] --until <when>');
|
|
558
|
+
return override(paths, {
|
|
559
|
+
action: 'focus',
|
|
560
|
+
project: sub,
|
|
561
|
+
...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
|
|
562
|
+
until: untilOf(values.until),
|
|
563
|
+
...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
|
|
564
|
+
});
|
|
565
|
+
case 'pause':
|
|
566
|
+
return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
|
|
567
|
+
case 'ports':
|
|
568
|
+
process.exitCode = await portsCommand(paths, sub, { json: values.json === true, ...(values.prefer === undefined ? {} : { prefer: wholeNumber('--prefer', values.prefer) }) }, brokerIo());
|
|
569
|
+
return;
|
|
570
|
+
case 'queue':
|
|
571
|
+
return queue(paths, values.json === true, values.fresh === true);
|
|
572
|
+
case 'rules':
|
|
573
|
+
if (sub === undefined)
|
|
574
|
+
return rules(paths, values.json === true);
|
|
575
|
+
if (sub === 'test' && arg !== undefined)
|
|
576
|
+
return rulesTest(paths, arg, values.json === true);
|
|
577
|
+
throw new ConductorError('usage: conduct rules [--json] | conduct rules test <file> [--json]');
|
|
233
578
|
case 'retire':
|
|
234
579
|
if (sub === undefined)
|
|
235
580
|
throw new ConductorError('usage: conduct retire <worker> [--force]');
|
|
236
581
|
return retire(paths, sub, values.force === true);
|
|
237
582
|
case 'serve':
|
|
238
583
|
return serve(paths);
|
|
584
|
+
case 'start':
|
|
585
|
+
return start(paths);
|
|
239
586
|
case 'status':
|
|
240
587
|
return status(paths, values.json === true);
|
|
241
588
|
case 'stop':
|
|
242
589
|
await stopDaemon(paths);
|
|
243
590
|
return;
|
|
591
|
+
case 'unpause':
|
|
592
|
+
return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
|
|
244
593
|
case 'upgrade':
|
|
245
|
-
return upgradeCommand(paths, values.tag);
|
|
594
|
+
return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
|
|
595
|
+
case 'why':
|
|
596
|
+
if (sub === undefined)
|
|
597
|
+
throw new ConductorError('usage: conduct why <event id | item>');
|
|
598
|
+
return why(paths, sub, values.json === true);
|
|
246
599
|
default:
|
|
247
600
|
throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
|
|
248
601
|
}
|
|
249
602
|
}
|
|
603
|
+
function modDeps() {
|
|
604
|
+
return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
|
|
605
|
+
}
|
|
606
|
+
function noInbox() {
|
|
607
|
+
return olderDaemon('no inbox');
|
|
608
|
+
}
|
|
609
|
+
// GET /notify, or a ConductorError from a daemon older than the notifier.
|
|
610
|
+
async function notifyAnswer(paths, request) {
|
|
611
|
+
const answer = await callApi(paths, request);
|
|
612
|
+
if (answer.status === 404)
|
|
613
|
+
throw olderDaemon('no notifier');
|
|
614
|
+
return expectOk(Promise.resolve(answer));
|
|
615
|
+
}
|
|
616
|
+
// `conduct notify profile`: the active profile, or switch to <name>.
|
|
617
|
+
async function notifyProfile(paths, name, until) {
|
|
618
|
+
if (name === undefined) {
|
|
619
|
+
if (until !== undefined)
|
|
620
|
+
throw new ConductorError('usage: conduct notify profile <name> --until <when>');
|
|
621
|
+
return notifyStatus(paths, false);
|
|
622
|
+
}
|
|
623
|
+
const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
|
|
624
|
+
const status = notifyStatusSchema.parse(await notifyAnswer(paths, { body, method: 'POST', path: '/notify/profile' }));
|
|
625
|
+
notifyLines(status).forEach((line) => out(line));
|
|
626
|
+
}
|
|
627
|
+
async function notifyStatus(paths, json) {
|
|
628
|
+
const status = notifyStatusSchema.parse(await notifyAnswer(paths, { method: 'GET', path: '/notify' }));
|
|
629
|
+
if (json)
|
|
630
|
+
return out(JSON.stringify(status, null, 2));
|
|
631
|
+
notifyLines(status).forEach((line) => out(line));
|
|
632
|
+
}
|
|
633
|
+
async function notifyTest(paths, options) {
|
|
634
|
+
if (options.raise && options.channel)
|
|
635
|
+
throw new ConductorError('--raise routes the item by the profile; it takes no --channel');
|
|
636
|
+
const body = { ...(options.channel ? { channel: options.channel } : {}), ...(options.kind ? { kind: options.kind } : {}), raise: options.raise };
|
|
637
|
+
// Pushover can take its 15 s, after a Credential Manager read.
|
|
638
|
+
const answer = await notifyAnswer(paths, { body, method: 'POST', path: '/notify/test', timeoutMs: 60_000 });
|
|
639
|
+
if (options.raise) {
|
|
640
|
+
const { item } = z.object({ item: inboxItemSchema }).parse(answer);
|
|
641
|
+
return out(`raised inbox item #${item.id} (${item.kind}); \`conduct done ${item.id}\` clears it`);
|
|
642
|
+
}
|
|
643
|
+
const result = testResultSchema.parse(answer);
|
|
644
|
+
if (result.channels.length === 0)
|
|
645
|
+
return out(`profile ${result.profile} sends a ${body.kind ?? 'npm-otp'} item nowhere`);
|
|
646
|
+
out(`profile ${result.profile}${result.quietNow ? ' (quiet hours now: a real item would wait for the summary)' : ''}`);
|
|
647
|
+
result.results.forEach((r) => out(` ${r.channel}: ${r.ok ? 'ok' : 'failed'}: ${r.detail}`));
|
|
648
|
+
if (result.results.some((r) => !r.ok))
|
|
649
|
+
process.exitCode = 1;
|
|
650
|
+
}
|
|
250
651
|
// npm is a .cmd shim on Windows, which starts through a shell alone. The only argument from input is
|
|
251
652
|
// the tag, which parseTag limits to dev or latest.
|
|
252
653
|
function npm(args, timeoutMs) {
|
|
253
654
|
return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
|
|
254
655
|
}
|
|
656
|
+
function olderDaemon(what) {
|
|
657
|
+
return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
|
|
658
|
+
'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
|
|
659
|
+
]);
|
|
660
|
+
}
|
|
661
|
+
// The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
|
|
662
|
+
async function openItems(paths) {
|
|
663
|
+
const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
|
|
664
|
+
if (answer.status === 404)
|
|
665
|
+
return null;
|
|
666
|
+
return inboxBody.parse(await expectOk(Promise.resolve(answer))).items;
|
|
667
|
+
}
|
|
255
668
|
function out(line) {
|
|
256
669
|
process.stdout.write(`${line}\n`);
|
|
257
670
|
}
|
|
671
|
+
// `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
|
|
672
|
+
async function override(paths, body) {
|
|
673
|
+
const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
|
|
674
|
+
if (answer.status === 404)
|
|
675
|
+
throw olderDaemon('no overrides');
|
|
676
|
+
const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
677
|
+
const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
|
|
678
|
+
for (const o of set) {
|
|
679
|
+
const who = o.project ?? 'every project';
|
|
680
|
+
if (o.kind === PAUSE)
|
|
681
|
+
out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
|
|
682
|
+
else
|
|
683
|
+
out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
|
|
684
|
+
}
|
|
685
|
+
// A pause of every project still holds a project unpaused on its own.
|
|
686
|
+
const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
|
|
687
|
+
if (body.action === 'unpause' && body.project !== undefined && all)
|
|
688
|
+
out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
|
|
689
|
+
}
|
|
690
|
+
// The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
|
|
691
|
+
// bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
|
|
692
|
+
async function planItem(paths, ref) {
|
|
693
|
+
if (ref.startsWith('#'))
|
|
694
|
+
return inboxId(ref);
|
|
695
|
+
if (/^\d+$/.test(ref)) {
|
|
696
|
+
throw new ConductorError(`"${ref}" is ambiguous`, [`name the item, such as ATK-${ref}, or the inbox id as #${ref}`]);
|
|
697
|
+
}
|
|
698
|
+
const items = await openItems(paths);
|
|
699
|
+
if (items === null)
|
|
700
|
+
throw noInbox();
|
|
701
|
+
const plans = items.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.toUpperCase());
|
|
702
|
+
if (plans.length === 1 && plans[0])
|
|
703
|
+
return plans[0].id;
|
|
704
|
+
if (plans.length === 0)
|
|
705
|
+
throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
|
|
706
|
+
throw new ConductorError(`several plans of ${ref} wait`, plans.map((p) => `#${p.id} in ${p.project ?? '?'}: pass #${p.id}`));
|
|
707
|
+
}
|
|
258
708
|
function projectLine(p) {
|
|
259
709
|
const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
|
|
260
710
|
return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
|
|
261
711
|
}
|
|
712
|
+
// The registered projects: from the daemon, or from the store when it isn't running.
|
|
713
|
+
async function projectsNow(paths) {
|
|
714
|
+
try {
|
|
715
|
+
return projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
if (!(error instanceof NotRunningError))
|
|
719
|
+
throw error;
|
|
720
|
+
return withStore(paths, (store) => store.listProjects());
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function queue(paths, json, fresh) {
|
|
724
|
+
// A fresh read asks every project's tracker, within the shared rate budget.
|
|
725
|
+
const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
|
|
726
|
+
if (answer.status === 404)
|
|
727
|
+
throw olderDaemon('no scheduler');
|
|
728
|
+
const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
729
|
+
if (json)
|
|
730
|
+
return out(JSON.stringify(view, null, 2));
|
|
731
|
+
queueLines(view).forEach((line) => out(line));
|
|
732
|
+
}
|
|
262
733
|
async function retire(paths, ref, force) {
|
|
263
734
|
const worker = await findWorker(paths, ref);
|
|
264
735
|
const { body, status } = await callApi(paths, {
|
|
@@ -286,23 +757,86 @@ async function retire(paths, ref, force) {
|
|
|
286
757
|
out(`retired ${worker.name}`);
|
|
287
758
|
steps.forEach((s) => out(` ${s}`));
|
|
288
759
|
}
|
|
760
|
+
// `conduct rules`: from the daemon, or straight from the files when it isn't running.
|
|
761
|
+
async function rules(paths, json) {
|
|
762
|
+
const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
|
|
763
|
+
if (json)
|
|
764
|
+
return out(JSON.stringify(view, null, 2));
|
|
765
|
+
rulesLines(view).forEach((line) => out(line));
|
|
766
|
+
}
|
|
767
|
+
// The daemon's answer, or, when none runs, the same thing worked out here from the files.
|
|
768
|
+
async function rulesAnswer(paths, request, parse, local) {
|
|
769
|
+
let answer;
|
|
770
|
+
try {
|
|
771
|
+
answer = await callApi(paths, request);
|
|
772
|
+
}
|
|
773
|
+
catch (error) {
|
|
774
|
+
if (!(error instanceof NotRunningError))
|
|
775
|
+
throw error;
|
|
776
|
+
process.stderr.write('conduct: the conductor is not running; read from the files\n');
|
|
777
|
+
const config = loadConfig(paths.config);
|
|
778
|
+
return withStore(paths, (store) => {
|
|
779
|
+
const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
|
|
780
|
+
service.reload();
|
|
781
|
+
return local(service);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (answer.status === 404)
|
|
785
|
+
throw olderDaemon('no rules engine');
|
|
786
|
+
return parse(await expectOk(Promise.resolve(answer)));
|
|
787
|
+
}
|
|
788
|
+
async function rulesTest(paths, file, json) {
|
|
789
|
+
let text;
|
|
790
|
+
try {
|
|
791
|
+
text = readFileSync(file, 'utf8');
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
throw new ConductorError(`could not read ${file}`, [String(error)]);
|
|
795
|
+
}
|
|
796
|
+
const sample = parseJsonc(text, z.unknown(), file);
|
|
797
|
+
const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
|
|
798
|
+
if (json)
|
|
799
|
+
return out(JSON.stringify(result, null, 2));
|
|
800
|
+
testLines(result).forEach((line) => out(line));
|
|
801
|
+
}
|
|
289
802
|
async function serve(paths) {
|
|
290
803
|
const daemon = await startDaemon(paths, { version: version() });
|
|
291
804
|
out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
|
|
292
805
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
|
|
293
806
|
process.once(signal, () => void daemon.stop(signal));
|
|
294
807
|
}
|
|
295
|
-
|
|
296
|
-
// names a dead process, which the next start replaces.
|
|
297
|
-
process.on('uncaughtException', (error) => {
|
|
298
|
-
daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
|
|
299
|
-
process.exit(1);
|
|
300
|
-
});
|
|
808
|
+
logCrashes(daemon.log);
|
|
301
809
|
await daemon.closed;
|
|
302
810
|
}
|
|
303
811
|
function sleep(ms) {
|
|
304
812
|
return new Promise((done) => setTimeout(done, ms));
|
|
305
813
|
}
|
|
814
|
+
// `conduct start`: the daemon, detached from this process, so it outlives the shell or the Claude
|
|
815
|
+
// session that started it (the console's start runs this). Nothing when one answers already.
|
|
816
|
+
async function start(paths) {
|
|
817
|
+
const pid = readPid(paths.pid);
|
|
818
|
+
if (pid !== null && (await ping(paths, pid)))
|
|
819
|
+
return out(`the conductor is running (pid ${pid})`);
|
|
820
|
+
// A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
|
|
821
|
+
// start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
|
|
822
|
+
if (pid !== null && processAlive(pid)) {
|
|
823
|
+
try {
|
|
824
|
+
return out(`the conductor is running (pid ${await awaitDaemon(paths)})`);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// Not a daemon that answers: start one, which replaces a stale lock (or says who holds it).
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
out(`started the conductor ${await restartDaemon(daemonStarter(paths, realpathSync(fileURLToPath(import.meta.url))))}`);
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
const holder = readPid(paths.pid);
|
|
835
|
+
if (holder === null || !processAlive(holder))
|
|
836
|
+
throw error;
|
|
837
|
+
throw new ConductorError(`process ${holder} holds the conductor's lock (${paths.pid}) but doesn't answer`, ['if it is a hung conductor, stop it, then run conduct start again', `see ${paths.log}`]);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
306
840
|
async function status(paths, json) {
|
|
307
841
|
let projects;
|
|
308
842
|
try {
|
|
@@ -317,15 +851,28 @@ async function status(paths, json) {
|
|
|
317
851
|
return;
|
|
318
852
|
}
|
|
319
853
|
const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
|
|
854
|
+
// A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
|
|
855
|
+
const items = await openItems(paths);
|
|
856
|
+
// Null from a daemon older than the broker.
|
|
857
|
+
const locks = await heldLocks(paths);
|
|
320
858
|
const pid = readPid(paths.pid);
|
|
321
859
|
const port = readEndpoint(paths)?.port ?? null;
|
|
322
860
|
if (json)
|
|
323
|
-
return out(JSON.stringify({ home: paths.home, pid, port, projects, running: true, workers }, null, 2));
|
|
861
|
+
return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers }, null, 2));
|
|
324
862
|
out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
|
|
325
863
|
out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
|
|
326
864
|
projects.forEach((p) => out(projectLine(p)));
|
|
327
865
|
out(workers.length === 0 ? 'no workers' : 'workers:');
|
|
328
866
|
workers.forEach((w) => out(workerLine(w)));
|
|
867
|
+
if (locks === null)
|
|
868
|
+
out('locks: not in this daemon (restart it to run this build)');
|
|
869
|
+
else
|
|
870
|
+
out(locks.length === 0 ? 'locks: none held' : 'locks:');
|
|
871
|
+
locks?.forEach((l) => out(lockLine(l)));
|
|
872
|
+
if (items === null)
|
|
873
|
+
out('inbox: not in this daemon (restart it to run this build)');
|
|
874
|
+
else
|
|
875
|
+
out(items.length === 0 ? 'inbox: empty' : `inbox: ${items.length} open (conduct inbox)`);
|
|
329
876
|
}
|
|
330
877
|
// True when it stopped a daemon, false when none ran.
|
|
331
878
|
async function stopDaemon(paths) {
|
|
@@ -355,10 +902,14 @@ async function stopDaemon(paths) {
|
|
|
355
902
|
function uninstall() {
|
|
356
903
|
out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
|
|
357
904
|
}
|
|
358
|
-
|
|
905
|
+
// --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
|
|
906
|
+
function untilOf(text) {
|
|
907
|
+
return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
|
|
908
|
+
}
|
|
909
|
+
async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
359
910
|
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
360
|
-
await upgrade({ tag: parseTag(tag) }, {
|
|
361
|
-
|
|
911
|
+
await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
|
|
912
|
+
...daemonStarter(paths, cliPath),
|
|
362
913
|
cliPath,
|
|
363
914
|
installedVersion: version,
|
|
364
915
|
npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
|
|
@@ -376,15 +927,7 @@ async function upgradeCommand(paths, tag) {
|
|
|
376
927
|
return found;
|
|
377
928
|
},
|
|
378
929
|
out,
|
|
379
|
-
runTask: runStartup,
|
|
380
|
-
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
381
|
-
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
382
|
-
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
383
|
-
.on('error', () => { })
|
|
384
|
-
.unref(),
|
|
385
930
|
stopDaemon: () => stopDaemon(paths),
|
|
386
|
-
// The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
|
|
387
|
-
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
388
931
|
});
|
|
389
932
|
}
|
|
390
933
|
// Read from disk each time: upgrade asks again after npm has replaced the package.
|
|
@@ -415,9 +958,31 @@ async function wait(argv) {
|
|
|
415
958
|
throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
|
|
416
959
|
process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
|
|
417
960
|
}
|
|
418
|
-
function
|
|
961
|
+
function wholeNumber(flag, text) {
|
|
962
|
+
if (!/^-?\d+$/.test(text))
|
|
963
|
+
throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
|
|
964
|
+
return Number(text);
|
|
965
|
+
}
|
|
966
|
+
// `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
|
|
967
|
+
// each of the item's newest actions.
|
|
968
|
+
async function why(paths, ref, json) {
|
|
969
|
+
const byId = /^#?(\d+)$/.exec(ref)?.[1];
|
|
970
|
+
const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
|
|
971
|
+
if (answer.status === 404 && byId !== undefined) {
|
|
972
|
+
const body = errorBody.safeParse(answer.body);
|
|
973
|
+
throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
|
|
974
|
+
}
|
|
975
|
+
const body = await expectOk(Promise.resolve(answer));
|
|
976
|
+
const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
|
|
977
|
+
if (json)
|
|
978
|
+
return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
|
|
979
|
+
if (whys.length === 0)
|
|
980
|
+
return out(`no action on ${ref} was caused by a rule`);
|
|
981
|
+
whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
|
|
982
|
+
}
|
|
983
|
+
function withStore(paths, use, options = {}) {
|
|
419
984
|
mkdirSync(paths.home, { recursive: true });
|
|
420
|
-
const store = openStore(paths.db);
|
|
985
|
+
const store = openStore(paths.db, undefined, options);
|
|
421
986
|
try {
|
|
422
987
|
return use(store);
|
|
423
988
|
}
|
|
@@ -425,12 +990,13 @@ function withStore(paths, use) {
|
|
|
425
990
|
store.close();
|
|
426
991
|
}
|
|
427
992
|
}
|
|
428
|
-
// One line per worker: name, project, status and session activity, its waits, then spend.
|
|
993
|
+
// One line per worker: name, project, status and session activity, its phase and waits, then spend.
|
|
429
994
|
function workerLine(w) {
|
|
430
995
|
const activity = w.status === 'running' ? `running, ${w.activity}` : w.status;
|
|
431
996
|
const waits = w.waits.map((x) => (x.resolvedAt ? `${x.condition} (resolved)` : x.condition));
|
|
432
997
|
return [
|
|
433
998
|
` ${w.name} ${w.project} ${activity}`,
|
|
999
|
+
...(w.phase ? [`phase ${w.phase}`] : []),
|
|
434
1000
|
...(waits.length > 0 ? [`waits: ${waits.join(', ')}`] : []),
|
|
435
1001
|
`spend ${formatTokens(w.spend.today)} today, ${formatTokens(w.spend.total)} total`,
|
|
436
1002
|
].join(' · ');
|