@detergent-software/conductor 1.0.0-dev.1 → 1.0.0-dev.11
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 +93 -0
- package/README.md +210 -9
- package/build/cli.js +456 -21
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +5 -6
- package/build/conditions.js.map +1 -1
- package/build/config.js +92 -1
- package/build/config.js.map +1 -1
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +96 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/inbox.js +385 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +56 -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 +188 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +393 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +82 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +1 -0
- package/build/paths.js.map +1 -1
- package/build/profile.js +7 -0
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +178 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +3 -1
- package/build/registry.js.map +1 -1
- 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 +842 -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 +204 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +8 -0
- package/build/spend.js.map +1 -1
- package/build/startup.js +30 -0
- package/build/startup.js.map +1 -1
- package/build/store.js +271 -0
- package/build/store.js.map +1 -1
- package/build/token.js +3 -3
- package/build/token.js.map +1 -1
- package/build/upgrade.js +105 -0
- package/build/upgrade.js.map +1 -0
- package/build/wait.js +32 -32
- package/build/wait.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-queue.js +124 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +16 -7
- 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 +395 -0
- package/mod/hooks/register.tsx +505 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
package/build/cli.js
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
4
|
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 { callApi, NotRunningError, readEndpoint } from "./client.js";
|
|
8
|
+
import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
|
|
9
|
+
import { loadConfig } from "./config.js";
|
|
9
10
|
import { startDaemon } from "./daemon.js";
|
|
10
11
|
import { ConductorError } from "./errors.js";
|
|
11
|
-
import {
|
|
12
|
+
import { eventsCommand } from "./events.js";
|
|
13
|
+
import { inboxLines } from "./inbox.js";
|
|
14
|
+
import { parseJsonc } from "./jsonc.js";
|
|
15
|
+
import { processAlive, readPid } from "./lock.js";
|
|
12
16
|
import { fleetWorkerSchema } from "./manager.js";
|
|
17
|
+
import { installMod, removeMod } from "./mod.js";
|
|
18
|
+
import { noteCommand } from "./note.js";
|
|
19
|
+
import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
|
|
20
|
+
import { overrideViewSchema, PAUSE } from "./overrides.js";
|
|
13
21
|
import { conductorPaths } from "./paths.js";
|
|
14
22
|
import { registerProject } from "./registry.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
23
|
+
import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
|
|
24
|
+
import { resolveUntil } from "./rules.js";
|
|
25
|
+
import { outputOf, run } from "./run.js";
|
|
26
|
+
import { queueLines, queueViewSchema } from "./scheduler.js";
|
|
27
|
+
import { formatTokens } from "./spend.js";
|
|
28
|
+
import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
|
|
29
|
+
import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
|
|
30
|
+
import { parseTag, upgrade } from "./upgrade.js";
|
|
17
31
|
import { waitCommand } from "./wait.js";
|
|
32
|
+
import { whyLines, whySchema } from "./why.js";
|
|
18
33
|
const USAGE = `Usage: conduct <command>
|
|
19
34
|
|
|
20
35
|
serve run the daemon in this process (one per machine)
|
|
@@ -26,6 +41,37 @@ const USAGE = `Usage: conduct <command>
|
|
|
26
41
|
[--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
|
|
27
42
|
retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
|
|
28
43
|
attach <worker> wake a parked worker (no event text) and open it in a tab
|
|
44
|
+
inbox [--json] what waits on you: each open item with its commands and links
|
|
45
|
+
approve <item> [--text <text>]
|
|
46
|
+
approve a plan: post "approve" on the item as you, wake its worker;
|
|
47
|
+
<item> is ATK-312, or the inbox item's id as #12
|
|
48
|
+
revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
|
|
49
|
+
done <id> mark an inbox item done (a physical action, or one you handled)
|
|
50
|
+
notify [--json] the notification profile, quiet hours, and each channel's state
|
|
51
|
+
notify profile [<name>] [--until <when>]
|
|
52
|
+
switch the profile now (built in: default, away, focus); --until 17:00 or
|
|
53
|
+
a date and time hands back to the one before it then
|
|
54
|
+
notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
|
|
55
|
+
push a sample item (default npm-otp) on the profile's channels now;
|
|
56
|
+
--raise puts a real item in the inbox instead (quiet hours, reminders)
|
|
57
|
+
queue [--json] [--fresh] what the scheduler picks next and why the rest wait (--fresh: read now)
|
|
58
|
+
pause [<project>] [--until <when>]
|
|
59
|
+
no new launches in the project (every project without one), until
|
|
60
|
+
17:00, tomorrow, monday or a date and time; unpause ends it
|
|
61
|
+
unpause [<project>] launch again
|
|
62
|
+
focus <project> [--slots <n>] [--weight <n>] --until <when>
|
|
63
|
+
the project's slots or ordering weight, for a while
|
|
64
|
+
why <event id | item> [--json]
|
|
65
|
+
the rule behind an action, and the events that led to it
|
|
66
|
+
rules [--json] the rules in force (project, machine, built-in defaults), what replaces
|
|
67
|
+
what, and any rules file refused; machine rules are ~/.conductor/rules.json
|
|
68
|
+
rules test <file> [--json]
|
|
69
|
+
dry-run the rules on a sample, a JSON file such as
|
|
70
|
+
{ "event": "pr.comment", "project": "atk", "fields": { "wait.kind": "merge" } }
|
|
71
|
+
(no event: a state rule's subject); acts on nothing
|
|
72
|
+
note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
|
|
73
|
+
record its phase on the worker (this checkout's, or --worker <worker>);
|
|
74
|
+
exit 3 when nobody posted it (no conductor, or the tracker refused it)
|
|
29
75
|
wait <condition> wait for an outside event: in a worker's worktree with the daemon
|
|
30
76
|
running, register it and end the turn (the daemon resumes the session);
|
|
31
77
|
otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
|
|
@@ -35,15 +81,29 @@ const USAGE = `Usage: conduct <command>
|
|
|
35
81
|
health <env> --version <x> | --commit <sha>
|
|
36
82
|
approval [<item>] an approve / revise: comment after the item's newest Plan note
|
|
37
83
|
item <item> <state> | item <item> label|note <value>
|
|
38
|
-
time <when> such as 2026-10-10T09:00 (local)
|
|
84
|
+
time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
|
|
39
85
|
<full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
|
|
40
86
|
[--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
|
|
87
|
+
events [--since <id>] the event log after <id> (all of it without), one JSON event per line
|
|
88
|
+
events --follow [--since <id>]
|
|
89
|
+
keep following it (live without --since), reconnecting when the
|
|
90
|
+
conductor restarts; state lines conduct.connected / conduct.disconnected
|
|
41
91
|
install-startup start the daemon at logon (Windows Task Scheduler)
|
|
42
92
|
install-startup --remove remove that logon task
|
|
93
|
+
install-mod install the conductor mod in Claude Code (user scope) from this package:
|
|
94
|
+
the status line in every session, the inbox band, pane and commands in
|
|
95
|
+
the console (a session started with CONDUCTOR_CONSOLE=1)
|
|
96
|
+
install-mod --remove uninstall it
|
|
97
|
+
upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
|
|
98
|
+
the tag the installed version came from), start the daemon again
|
|
99
|
+
[--allow-downgrade] install the tag's version even when it is older than the installed one
|
|
43
100
|
|
|
44
101
|
A <worker> is its item (ATK-309) or its name (probe-ATK-309).
|
|
45
102
|
Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
|
|
46
103
|
const STOP_WAIT_MS = 10_000;
|
|
104
|
+
// A restarted daemon opens its store and binds the port; under the logon task node starts cold.
|
|
105
|
+
const START_WAIT_MS = 30_000;
|
|
106
|
+
const NPM_INSTALL_TIMEOUT_MS = 5 * 60_000;
|
|
47
107
|
// A launch makes a worktree (pnpm install) before the session starts.
|
|
48
108
|
const LAUNCH_WAIT_MS = 15 * 60_000;
|
|
49
109
|
// The daemon's worst cases, so the CLI never gives up on an operation that still runs: retire
|
|
@@ -51,6 +111,8 @@ const LAUNCH_WAIT_MS = 15 * 60_000;
|
|
|
51
111
|
// attach waits out the settle time (up to 5 min), resumes (120 s) and opens the tab (60 s).
|
|
52
112
|
const RETIRE_TIMEOUT_MS = 12 * 60_000;
|
|
53
113
|
const ATTACH_TIMEOUT_MS = 9 * 60_000;
|
|
114
|
+
// Approve and revise ask Linear twice (the issue, then the comment), each within 20 s.
|
|
115
|
+
const ANSWER_TIMEOUT_MS = 60_000;
|
|
54
116
|
const projectsBody = z.object({ projects: z.array(projectSchema) });
|
|
55
117
|
const addBody = z.object({ created: z.boolean(), project: projectSchema });
|
|
56
118
|
const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
|
|
@@ -70,6 +132,13 @@ const retireBody = z.object({
|
|
|
70
132
|
worker: workerSchema,
|
|
71
133
|
});
|
|
72
134
|
const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
|
|
135
|
+
const inboxBody = z.object({ items: z.array(inboxItemSchema) });
|
|
136
|
+
const answerBody = z.object({
|
|
137
|
+
comment: z.object({ id: z.string(), url: z.string().nullable() }).nullable(),
|
|
138
|
+
item: inboxItemSchema,
|
|
139
|
+
waits: z.array(waitSchema),
|
|
140
|
+
});
|
|
141
|
+
const answerRefusal = z.object({ details: z.array(z.string()).optional(), error: z.string(), posted: z.enum(['no', 'unknown']).optional() });
|
|
73
142
|
async function addProject(paths, path) {
|
|
74
143
|
const absolute = resolve(path);
|
|
75
144
|
let result;
|
|
@@ -80,10 +149,38 @@ async function addProject(paths, path) {
|
|
|
80
149
|
if (!(error instanceof NotRunningError))
|
|
81
150
|
throw error;
|
|
82
151
|
// No daemon: write the store directly. The daemon reads it when it starts.
|
|
83
|
-
|
|
152
|
+
// Its rules are checked against config.json here too, as the daemon would.
|
|
153
|
+
const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
|
|
154
|
+
result = withStore(paths, (store) => registerProject(store, absolute, check));
|
|
84
155
|
}
|
|
85
156
|
out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
|
|
86
157
|
}
|
|
158
|
+
// `conduct approve|revise|done`: Jason's answer to an inbox item, by id, or for approve and revise
|
|
159
|
+
// by the item whose plan waits.
|
|
160
|
+
async function answer(paths, ref, request) {
|
|
161
|
+
const target = request.action === 'done' ? inboxId(ref) : await planItem(paths, ref);
|
|
162
|
+
const { body, status } = await callApi(paths, { body: request, method: 'POST', path: `/inbox/${target}/resolve`, timeoutMs: ANSWER_TIMEOUT_MS });
|
|
163
|
+
if (status !== 200) {
|
|
164
|
+
const refusal = answerRefusal.safeParse(body).data;
|
|
165
|
+
const details = [...(refusal?.details ?? [])];
|
|
166
|
+
if (status === 502 && refusal?.posted === 'unknown')
|
|
167
|
+
details.push('the comment may be on the item already: look before answering again');
|
|
168
|
+
if (status === 404) {
|
|
169
|
+
if ((await openItems(paths)) === null)
|
|
170
|
+
throw noInbox();
|
|
171
|
+
throw new ConductorError(`no inbox item #${target}`, ['`conduct inbox` lists the open ones']);
|
|
172
|
+
}
|
|
173
|
+
throw new ConductorError(refusal?.error ?? `the API answered ${status}`, details);
|
|
174
|
+
}
|
|
175
|
+
const result = answerBody.parse(body);
|
|
176
|
+
const { item } = result;
|
|
177
|
+
if (request.action === 'done')
|
|
178
|
+
return out(`done: #${item.id} (${item.kind}${item.item ? ` ${item.item}` : ''})`);
|
|
179
|
+
out(`${request.action === 'approve' ? 'approved' : 'asked for a revision of'} ${item.item ?? `#${item.id}`}: ${result.comment?.url ?? result.comment?.id ?? 'comment posted'}`);
|
|
180
|
+
out(result.waits.length > 0
|
|
181
|
+
? `its worker's approval wait resolved; the conductor resumes it`
|
|
182
|
+
: 'no worker waits on its approval here: the worker reads the comment when it next looks');
|
|
183
|
+
}
|
|
87
184
|
async function attach(paths, ref) {
|
|
88
185
|
const worker = await findWorker(paths, ref);
|
|
89
186
|
const result = attachBody.parse(await expectOk(callApi(paths, { method: 'POST', path: `/workers/${worker.id}/attach`, timeoutMs: ATTACH_TIMEOUT_MS })));
|
|
@@ -91,6 +188,26 @@ async function attach(paths, ref) {
|
|
|
91
188
|
if (result.tab)
|
|
92
189
|
out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
|
|
93
190
|
}
|
|
191
|
+
// The pid of the daemon once it answers /health, for upgrade's restart.
|
|
192
|
+
async function awaitDaemon(paths) {
|
|
193
|
+
const deadline = Date.now() + START_WAIT_MS;
|
|
194
|
+
while (Date.now() < deadline) {
|
|
195
|
+
const pid = readPid(paths.pid);
|
|
196
|
+
if (pid !== null && (await ping(paths, pid)))
|
|
197
|
+
return pid;
|
|
198
|
+
await sleep(250);
|
|
199
|
+
}
|
|
200
|
+
throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
|
|
201
|
+
}
|
|
202
|
+
// `conduct events`: until Ctrl+C with --follow.
|
|
203
|
+
async function events(paths, follow, since) {
|
|
204
|
+
if (since !== undefined && !/^\d+$/.test(since))
|
|
205
|
+
throw new ConductorError('--since takes an event id, such as --since 120');
|
|
206
|
+
const stop = new AbortController();
|
|
207
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
|
|
208
|
+
process.once(signal, () => stop.abort());
|
|
209
|
+
await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
|
|
210
|
+
}
|
|
94
211
|
async function expectOk(call) {
|
|
95
212
|
const { body, status } = await call;
|
|
96
213
|
if (status >= 200 && status < 300)
|
|
@@ -109,13 +226,22 @@ async function findWorker(paths, ref) {
|
|
|
109
226
|
throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
|
|
110
227
|
throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
|
|
111
228
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
return
|
|
118
|
-
|
|
229
|
+
async function inbox(paths, json) {
|
|
230
|
+
const items = await openItems(paths);
|
|
231
|
+
if (items === null)
|
|
232
|
+
throw noInbox();
|
|
233
|
+
if (json)
|
|
234
|
+
return out(JSON.stringify(items, null, 2));
|
|
235
|
+
if (items.length === 0)
|
|
236
|
+
return out('the inbox is empty');
|
|
237
|
+
inboxLines(items, new Date()).forEach((line) => out(line));
|
|
238
|
+
}
|
|
239
|
+
// An inbox id as `conduct inbox` prints it (#12), or bare (12).
|
|
240
|
+
function inboxId(ref) {
|
|
241
|
+
const id = /^#?(\d+)$/.exec(ref)?.[1];
|
|
242
|
+
if (id === undefined)
|
|
243
|
+
throw new ConductorError(`"${ref}" is not an inbox item id`, ['`conduct inbox` lists them as #<id>']);
|
|
244
|
+
return Number(id);
|
|
119
245
|
}
|
|
120
246
|
function install() {
|
|
121
247
|
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
@@ -168,19 +294,41 @@ async function main(argv) {
|
|
|
168
294
|
// `wait` has options of its own: its --version is a value, not this CLI's version.
|
|
169
295
|
if (argv[0] === 'wait')
|
|
170
296
|
return wait(argv.slice(1));
|
|
297
|
+
// `revise` takes free text, which option parsing would cut at a word such as --force. Every word
|
|
298
|
+
// after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
|
|
299
|
+
if (argv[0] === 'revise') {
|
|
300
|
+
const [ref, ...words] = argv.slice(1);
|
|
301
|
+
const text = words.join(' ').trim();
|
|
302
|
+
if (ref === undefined || ref.startsWith('-') || !text)
|
|
303
|
+
throw new ConductorError('usage: conduct revise <item> "<what to change>"');
|
|
304
|
+
return answer(conductorPaths(), ref, { action: 'revise', text });
|
|
305
|
+
}
|
|
171
306
|
const { positionals, values } = parseArgs({
|
|
172
307
|
allowPositionals: true,
|
|
173
308
|
args: argv,
|
|
174
309
|
options: {
|
|
310
|
+
'allow-downgrade': { type: 'boolean' },
|
|
311
|
+
channel: { type: 'string' },
|
|
175
312
|
conductor: { type: 'string' },
|
|
313
|
+
follow: { type: 'boolean' },
|
|
176
314
|
force: { type: 'boolean' },
|
|
315
|
+
fresh: { type: 'boolean' },
|
|
177
316
|
help: { short: 'h', type: 'boolean' },
|
|
178
317
|
json: { type: 'boolean' },
|
|
318
|
+
kind: { type: 'string' },
|
|
179
319
|
probe: { type: 'boolean' },
|
|
180
320
|
project: { type: 'string' },
|
|
181
321
|
prompt: { type: 'string' },
|
|
322
|
+
raise: { type: 'boolean' },
|
|
182
323
|
remove: { type: 'boolean' },
|
|
324
|
+
since: { type: 'string' },
|
|
325
|
+
slots: { type: 'string' },
|
|
326
|
+
tag: { type: 'string' },
|
|
327
|
+
text: { type: 'string' },
|
|
328
|
+
until: { type: 'string' },
|
|
183
329
|
version: { short: 'v', type: 'boolean' },
|
|
330
|
+
weight: { type: 'string' },
|
|
331
|
+
worker: { type: 'string' },
|
|
184
332
|
},
|
|
185
333
|
});
|
|
186
334
|
const paths = conductorPaths();
|
|
@@ -190,10 +338,24 @@ async function main(argv) {
|
|
|
190
338
|
if (values.help || command === undefined || command === 'help')
|
|
191
339
|
return out(USAGE);
|
|
192
340
|
switch (command) {
|
|
341
|
+
case 'approve':
|
|
342
|
+
if (sub === undefined)
|
|
343
|
+
throw new ConductorError('usage: conduct approve <item> [--text <text>]');
|
|
344
|
+
return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
|
|
193
345
|
case 'attach':
|
|
194
346
|
if (sub === undefined)
|
|
195
347
|
throw new ConductorError('usage: conduct attach <worker>');
|
|
196
348
|
return attach(paths, sub);
|
|
349
|
+
case 'done':
|
|
350
|
+
if (sub === undefined)
|
|
351
|
+
throw new ConductorError('usage: conduct done <inbox id>');
|
|
352
|
+
return answer(paths, sub, { action: 'done' });
|
|
353
|
+
case 'events':
|
|
354
|
+
return events(paths, values.follow === true, values.since);
|
|
355
|
+
case 'inbox':
|
|
356
|
+
return inbox(paths, values.json === true);
|
|
357
|
+
case 'install-mod':
|
|
358
|
+
return values.remove ? removeMod(modDeps()) : installMod(fileURLToPath(new URL('../mod', import.meta.url)), modDeps());
|
|
197
359
|
case 'install-startup':
|
|
198
360
|
return values.remove ? uninstall() : install();
|
|
199
361
|
case 'launch':
|
|
@@ -205,12 +367,49 @@ async function main(argv) {
|
|
|
205
367
|
project: values.project,
|
|
206
368
|
prompt: values.prompt,
|
|
207
369
|
});
|
|
370
|
+
case 'note':
|
|
371
|
+
if (sub === undefined || arg === undefined)
|
|
372
|
+
throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
|
|
373
|
+
process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
|
|
374
|
+
cwd: process.cwd(),
|
|
375
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
376
|
+
out,
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
case 'notify':
|
|
380
|
+
if (sub === undefined)
|
|
381
|
+
return notifyStatus(paths, values.json === true);
|
|
382
|
+
if (sub === 'profile')
|
|
383
|
+
return notifyProfile(paths, arg, values.until);
|
|
384
|
+
if (sub === 'test')
|
|
385
|
+
return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
|
|
386
|
+
throw new ConductorError('usage: conduct notify | conduct notify profile [<name>] [--until <when>] | conduct notify test');
|
|
208
387
|
case 'project':
|
|
209
388
|
if (sub === 'add' && arg !== undefined)
|
|
210
389
|
return addProject(paths, arg);
|
|
211
390
|
if (sub === 'list')
|
|
212
391
|
return listProjects(paths, values.json === true);
|
|
213
392
|
throw new ConductorError('usage: conduct project add <path> | conduct project list');
|
|
393
|
+
case 'focus':
|
|
394
|
+
if (sub === undefined || values.until === undefined)
|
|
395
|
+
throw new ConductorError('usage: conduct focus <project> [--slots <n>] [--weight <n>] --until <when>');
|
|
396
|
+
return override(paths, {
|
|
397
|
+
action: 'focus',
|
|
398
|
+
project: sub,
|
|
399
|
+
...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
|
|
400
|
+
until: untilOf(values.until),
|
|
401
|
+
...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
|
|
402
|
+
});
|
|
403
|
+
case 'pause':
|
|
404
|
+
return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
|
|
405
|
+
case 'queue':
|
|
406
|
+
return queue(paths, values.json === true, values.fresh === true);
|
|
407
|
+
case 'rules':
|
|
408
|
+
if (sub === undefined)
|
|
409
|
+
return rules(paths, values.json === true);
|
|
410
|
+
if (sub === 'test' && arg !== undefined)
|
|
411
|
+
return rulesTest(paths, arg, values.json === true);
|
|
412
|
+
throw new ConductorError('usage: conduct rules [--json] | conduct rules test <file> [--json]');
|
|
214
413
|
case 'retire':
|
|
215
414
|
if (sub === undefined)
|
|
216
415
|
throw new ConductorError('usage: conduct retire <worker> [--force]');
|
|
@@ -220,18 +419,139 @@ async function main(argv) {
|
|
|
220
419
|
case 'status':
|
|
221
420
|
return status(paths, values.json === true);
|
|
222
421
|
case 'stop':
|
|
223
|
-
|
|
422
|
+
await stopDaemon(paths);
|
|
423
|
+
return;
|
|
424
|
+
case 'unpause':
|
|
425
|
+
return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
|
|
426
|
+
case 'upgrade':
|
|
427
|
+
return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
|
|
428
|
+
case 'why':
|
|
429
|
+
if (sub === undefined)
|
|
430
|
+
throw new ConductorError('usage: conduct why <event id | item>');
|
|
431
|
+
return why(paths, sub, values.json === true);
|
|
224
432
|
default:
|
|
225
433
|
throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
|
|
226
434
|
}
|
|
227
435
|
}
|
|
436
|
+
function modDeps() {
|
|
437
|
+
return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
|
|
438
|
+
}
|
|
439
|
+
function noInbox() {
|
|
440
|
+
return olderDaemon('no inbox');
|
|
441
|
+
}
|
|
442
|
+
// GET /notify, or a ConductorError from a daemon older than the notifier.
|
|
443
|
+
async function notifyAnswer(paths, request) {
|
|
444
|
+
const answer = await callApi(paths, request);
|
|
445
|
+
if (answer.status === 404)
|
|
446
|
+
throw olderDaemon('no notifier');
|
|
447
|
+
return expectOk(Promise.resolve(answer));
|
|
448
|
+
}
|
|
449
|
+
// `conduct notify profile`: the active profile, or switch to <name>.
|
|
450
|
+
async function notifyProfile(paths, name, until) {
|
|
451
|
+
if (name === undefined) {
|
|
452
|
+
if (until !== undefined)
|
|
453
|
+
throw new ConductorError('usage: conduct notify profile <name> --until <when>');
|
|
454
|
+
return notifyStatus(paths, false);
|
|
455
|
+
}
|
|
456
|
+
const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
|
|
457
|
+
const status = notifyStatusSchema.parse(await notifyAnswer(paths, { body, method: 'POST', path: '/notify/profile' }));
|
|
458
|
+
notifyLines(status).forEach((line) => out(line));
|
|
459
|
+
}
|
|
460
|
+
async function notifyStatus(paths, json) {
|
|
461
|
+
const status = notifyStatusSchema.parse(await notifyAnswer(paths, { method: 'GET', path: '/notify' }));
|
|
462
|
+
if (json)
|
|
463
|
+
return out(JSON.stringify(status, null, 2));
|
|
464
|
+
notifyLines(status).forEach((line) => out(line));
|
|
465
|
+
}
|
|
466
|
+
async function notifyTest(paths, options) {
|
|
467
|
+
if (options.raise && options.channel)
|
|
468
|
+
throw new ConductorError('--raise routes the item by the profile; it takes no --channel');
|
|
469
|
+
const body = { ...(options.channel ? { channel: options.channel } : {}), ...(options.kind ? { kind: options.kind } : {}), raise: options.raise };
|
|
470
|
+
// Pushover can take its 15 s, after a Credential Manager read.
|
|
471
|
+
const answer = await notifyAnswer(paths, { body, method: 'POST', path: '/notify/test', timeoutMs: 60_000 });
|
|
472
|
+
if (options.raise) {
|
|
473
|
+
const { item } = z.object({ item: inboxItemSchema }).parse(answer);
|
|
474
|
+
return out(`raised inbox item #${item.id} (${item.kind}); \`conduct done ${item.id}\` clears it`);
|
|
475
|
+
}
|
|
476
|
+
const result = testResultSchema.parse(answer);
|
|
477
|
+
if (result.channels.length === 0)
|
|
478
|
+
return out(`profile ${result.profile} sends a ${body.kind ?? 'npm-otp'} item nowhere`);
|
|
479
|
+
out(`profile ${result.profile}${result.quietNow ? ' (quiet hours now: a real item would wait for the summary)' : ''}`);
|
|
480
|
+
result.results.forEach((r) => out(` ${r.channel}: ${r.ok ? 'ok' : 'failed'}: ${r.detail}`));
|
|
481
|
+
if (result.results.some((r) => !r.ok))
|
|
482
|
+
process.exitCode = 1;
|
|
483
|
+
}
|
|
484
|
+
// npm is a .cmd shim on Windows, which starts through a shell alone. The only argument from input is
|
|
485
|
+
// the tag, which parseTag limits to dev or latest.
|
|
486
|
+
function npm(args, timeoutMs) {
|
|
487
|
+
return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
|
|
488
|
+
}
|
|
489
|
+
function olderDaemon(what) {
|
|
490
|
+
return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
|
|
491
|
+
'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
|
|
492
|
+
]);
|
|
493
|
+
}
|
|
494
|
+
// The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
|
|
495
|
+
async function openItems(paths) {
|
|
496
|
+
const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
|
|
497
|
+
if (answer.status === 404)
|
|
498
|
+
return null;
|
|
499
|
+
return inboxBody.parse(await expectOk(Promise.resolve(answer))).items;
|
|
500
|
+
}
|
|
228
501
|
function out(line) {
|
|
229
502
|
process.stdout.write(`${line}\n`);
|
|
230
503
|
}
|
|
504
|
+
// `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
|
|
505
|
+
async function override(paths, body) {
|
|
506
|
+
const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
|
|
507
|
+
if (answer.status === 404)
|
|
508
|
+
throw olderDaemon('no overrides');
|
|
509
|
+
const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
|
|
510
|
+
const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
|
|
511
|
+
for (const o of set) {
|
|
512
|
+
const who = o.project ?? 'every project';
|
|
513
|
+
if (o.kind === PAUSE)
|
|
514
|
+
out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
|
|
515
|
+
else
|
|
516
|
+
out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
|
|
517
|
+
}
|
|
518
|
+
// A pause of every project still holds a project unpaused on its own.
|
|
519
|
+
const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
|
|
520
|
+
if (body.action === 'unpause' && body.project !== undefined && all)
|
|
521
|
+
out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
|
|
522
|
+
}
|
|
523
|
+
// The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
|
|
524
|
+
// bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
|
|
525
|
+
async function planItem(paths, ref) {
|
|
526
|
+
if (ref.startsWith('#'))
|
|
527
|
+
return inboxId(ref);
|
|
528
|
+
if (/^\d+$/.test(ref)) {
|
|
529
|
+
throw new ConductorError(`"${ref}" is ambiguous`, [`name the item, such as ATK-${ref}, or the inbox id as #${ref}`]);
|
|
530
|
+
}
|
|
531
|
+
const items = await openItems(paths);
|
|
532
|
+
if (items === null)
|
|
533
|
+
throw noInbox();
|
|
534
|
+
const plans = items.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.toUpperCase());
|
|
535
|
+
if (plans.length === 1 && plans[0])
|
|
536
|
+
return plans[0].id;
|
|
537
|
+
if (plans.length === 0)
|
|
538
|
+
throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
|
|
539
|
+
throw new ConductorError(`several plans of ${ref} wait`, plans.map((p) => `#${p.id} in ${p.project ?? '?'}: pass #${p.id}`));
|
|
540
|
+
}
|
|
231
541
|
function projectLine(p) {
|
|
232
542
|
const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
|
|
233
543
|
return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
|
|
234
544
|
}
|
|
545
|
+
async function queue(paths, json, fresh) {
|
|
546
|
+
// A fresh read asks every project's tracker, within the shared rate budget.
|
|
547
|
+
const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
|
|
548
|
+
if (answer.status === 404)
|
|
549
|
+
throw olderDaemon('no scheduler');
|
|
550
|
+
const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
|
|
551
|
+
if (json)
|
|
552
|
+
return out(JSON.stringify(view, null, 2));
|
|
553
|
+
queueLines(view).forEach((line) => out(line));
|
|
554
|
+
}
|
|
235
555
|
async function retire(paths, ref, force) {
|
|
236
556
|
const worker = await findWorker(paths, ref);
|
|
237
557
|
const { body, status } = await callApi(paths, {
|
|
@@ -259,6 +579,48 @@ async function retire(paths, ref, force) {
|
|
|
259
579
|
out(`retired ${worker.name}`);
|
|
260
580
|
steps.forEach((s) => out(` ${s}`));
|
|
261
581
|
}
|
|
582
|
+
// `conduct rules`: from the daemon, or straight from the files when it isn't running.
|
|
583
|
+
async function rules(paths, json) {
|
|
584
|
+
const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
|
|
585
|
+
if (json)
|
|
586
|
+
return out(JSON.stringify(view, null, 2));
|
|
587
|
+
rulesLines(view).forEach((line) => out(line));
|
|
588
|
+
}
|
|
589
|
+
// The daemon's answer, or, when none runs, the same thing worked out here from the files.
|
|
590
|
+
async function rulesAnswer(paths, request, parse, local) {
|
|
591
|
+
let answer;
|
|
592
|
+
try {
|
|
593
|
+
answer = await callApi(paths, request);
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (!(error instanceof NotRunningError))
|
|
597
|
+
throw error;
|
|
598
|
+
process.stderr.write('conduct: the conductor is not running; read from the files\n');
|
|
599
|
+
const config = loadConfig(paths.config);
|
|
600
|
+
return withStore(paths, (store) => {
|
|
601
|
+
const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
|
|
602
|
+
service.reload();
|
|
603
|
+
return local(service);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
if (answer.status === 404)
|
|
607
|
+
throw olderDaemon('no rules engine');
|
|
608
|
+
return parse(await expectOk(Promise.resolve(answer)));
|
|
609
|
+
}
|
|
610
|
+
async function rulesTest(paths, file, json) {
|
|
611
|
+
let text;
|
|
612
|
+
try {
|
|
613
|
+
text = readFileSync(file, 'utf8');
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
throw new ConductorError(`could not read ${file}`, [String(error)]);
|
|
617
|
+
}
|
|
618
|
+
const sample = parseJsonc(text, z.unknown(), file);
|
|
619
|
+
const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
|
|
620
|
+
if (json)
|
|
621
|
+
return out(JSON.stringify(result, null, 2));
|
|
622
|
+
testLines(result).forEach((line) => out(line));
|
|
623
|
+
}
|
|
262
624
|
async function serve(paths) {
|
|
263
625
|
const daemon = await startDaemon(paths, { version: version() });
|
|
264
626
|
out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
|
|
@@ -290,17 +652,24 @@ async function status(paths, json) {
|
|
|
290
652
|
return;
|
|
291
653
|
}
|
|
292
654
|
const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
|
|
655
|
+
// A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
|
|
656
|
+
const items = await openItems(paths);
|
|
293
657
|
const pid = readPid(paths.pid);
|
|
294
658
|
const port = readEndpoint(paths)?.port ?? null;
|
|
295
659
|
if (json)
|
|
296
|
-
return out(JSON.stringify({ home: paths.home, pid, port, projects, running: true, workers }, null, 2));
|
|
660
|
+
return out(JSON.stringify({ home: paths.home, inbox: items, pid, port, projects, running: true, workers }, null, 2));
|
|
297
661
|
out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
|
|
298
662
|
out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
|
|
299
663
|
projects.forEach((p) => out(projectLine(p)));
|
|
300
664
|
out(workers.length === 0 ? 'no workers' : 'workers:');
|
|
301
665
|
workers.forEach((w) => out(workerLine(w)));
|
|
666
|
+
if (items === null)
|
|
667
|
+
out('inbox: not in this daemon (restart it to run this build)');
|
|
668
|
+
else
|
|
669
|
+
out(items.length === 0 ? 'inbox: empty' : `inbox: ${items.length} open (conduct inbox)`);
|
|
302
670
|
}
|
|
303
|
-
|
|
671
|
+
// True when it stopped a daemon, false when none ran.
|
|
672
|
+
async function stopDaemon(paths) {
|
|
304
673
|
const pid = readPid(paths.pid);
|
|
305
674
|
try {
|
|
306
675
|
await expectOk(callApi(paths, { method: 'POST', path: '/shutdown' }));
|
|
@@ -309,7 +678,7 @@ async function stop(paths) {
|
|
|
309
678
|
if (!(error instanceof NotRunningError))
|
|
310
679
|
throw error;
|
|
311
680
|
out('the conductor is not running');
|
|
312
|
-
return;
|
|
681
|
+
return false;
|
|
313
682
|
}
|
|
314
683
|
const deadline = Date.now() + STOP_WAIT_MS;
|
|
315
684
|
while (readEndpoint(paths) !== null && Date.now() < deadline)
|
|
@@ -317,13 +686,56 @@ async function stop(paths) {
|
|
|
317
686
|
if (readEndpoint(paths) !== null) {
|
|
318
687
|
throw new ConductorError(`the conductor (pid ${pid ?? '?'}) did not stop within ${STOP_WAIT_MS / 1000} s`);
|
|
319
688
|
}
|
|
689
|
+
// The port file goes before the process ends; a start (the logon task's especially) waits for it.
|
|
690
|
+
const exitDeadline = Date.now() + STOP_WAIT_MS;
|
|
691
|
+
while (pid !== null && processAlive(pid) && Date.now() < exitDeadline)
|
|
692
|
+
await sleep(100);
|
|
320
693
|
out(`stopped the conductor (pid ${pid ?? '?'})`);
|
|
694
|
+
return true;
|
|
321
695
|
}
|
|
322
696
|
function uninstall() {
|
|
323
697
|
out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
|
|
324
698
|
}
|
|
699
|
+
// --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
|
|
700
|
+
function untilOf(text) {
|
|
701
|
+
return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
|
|
702
|
+
}
|
|
703
|
+
async function upgradeCommand(paths, tag, allowDowngrade) {
|
|
704
|
+
const cliPath = realpathSync(fileURLToPath(import.meta.url));
|
|
705
|
+
await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
|
|
706
|
+
awaitDaemon: () => awaitDaemon(paths),
|
|
707
|
+
cliPath,
|
|
708
|
+
installedVersion: version,
|
|
709
|
+
npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
|
|
710
|
+
npmRoot: async () => {
|
|
711
|
+
const result = await npm(['root', '--global']);
|
|
712
|
+
if (result.code !== 0)
|
|
713
|
+
throw new ConductorError('could not ask npm for its global folder', [outputOf(result)]);
|
|
714
|
+
return realpathSync(result.stdout.trim());
|
|
715
|
+
},
|
|
716
|
+
npmView: async (spec) => {
|
|
717
|
+
const result = await npm(['view', spec, 'version']);
|
|
718
|
+
const found = result.stdout.trim();
|
|
719
|
+
if (result.code !== 0 || found === '')
|
|
720
|
+
throw new ConductorError(`npm has no ${spec}`, [outputOf(result)]);
|
|
721
|
+
return found;
|
|
722
|
+
},
|
|
723
|
+
out,
|
|
724
|
+
runTask: runStartup,
|
|
725
|
+
// A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
|
|
726
|
+
// caller's directory, which it would hold open (a worktree it may later remove, say).
|
|
727
|
+
serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
|
|
728
|
+
.on('error', () => { })
|
|
729
|
+
.unref(),
|
|
730
|
+
stopDaemon: () => stopDaemon(paths),
|
|
731
|
+
// The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
|
|
732
|
+
taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
// Read from disk each time: upgrade asks again after npm has replaced the package.
|
|
325
736
|
function version() {
|
|
326
|
-
|
|
737
|
+
const file = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
738
|
+
return z.object({ version: z.string() }).parse(JSON.parse(readFileSync(file, 'utf8'))).version;
|
|
327
739
|
}
|
|
328
740
|
async function wait(argv) {
|
|
329
741
|
const { positionals, values } = parseArgs({
|
|
@@ -348,6 +760,28 @@ async function wait(argv) {
|
|
|
348
760
|
throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
|
|
349
761
|
process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
|
|
350
762
|
}
|
|
763
|
+
function wholeNumber(flag, text) {
|
|
764
|
+
if (!/^-?\d+$/.test(text))
|
|
765
|
+
throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
|
|
766
|
+
return Number(text);
|
|
767
|
+
}
|
|
768
|
+
// `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
|
|
769
|
+
// each of the item's newest actions.
|
|
770
|
+
async function why(paths, ref, json) {
|
|
771
|
+
const byId = /^#?(\d+)$/.exec(ref)?.[1];
|
|
772
|
+
const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
|
|
773
|
+
if (answer.status === 404 && byId !== undefined) {
|
|
774
|
+
const body = errorBody.safeParse(answer.body);
|
|
775
|
+
throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
|
|
776
|
+
}
|
|
777
|
+
const body = await expectOk(Promise.resolve(answer));
|
|
778
|
+
const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
|
|
779
|
+
if (json)
|
|
780
|
+
return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
|
|
781
|
+
if (whys.length === 0)
|
|
782
|
+
return out(`no action on ${ref} was caused by a rule`);
|
|
783
|
+
whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
|
|
784
|
+
}
|
|
351
785
|
function withStore(paths, use) {
|
|
352
786
|
mkdirSync(paths.home, { recursive: true });
|
|
353
787
|
const store = openStore(paths.db);
|
|
@@ -358,12 +792,13 @@ function withStore(paths, use) {
|
|
|
358
792
|
store.close();
|
|
359
793
|
}
|
|
360
794
|
}
|
|
361
|
-
// One line per worker: name, project, status and session activity, its waits, then spend.
|
|
795
|
+
// One line per worker: name, project, status and session activity, its phase and waits, then spend.
|
|
362
796
|
function workerLine(w) {
|
|
363
797
|
const activity = w.status === 'running' ? `running, ${w.activity}` : w.status;
|
|
364
798
|
const waits = w.waits.map((x) => (x.resolvedAt ? `${x.condition} (resolved)` : x.condition));
|
|
365
799
|
return [
|
|
366
800
|
` ${w.name} ${w.project} ${activity}`,
|
|
801
|
+
...(w.phase ? [`phase ${w.phase}`] : []),
|
|
367
802
|
...(waits.length > 0 ? [`waits: ${waits.join(', ')}`] : []),
|
|
368
803
|
`spend ${formatTokens(w.spend.today)} today, ${formatTokens(w.spend.total)} total`,
|
|
369
804
|
].join(' · ');
|