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