cawdev-cli 1.0.1-beta → 1.0.3-beta
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/lib/runner-config.mjs +81 -0
- package/package.json +1 -1
- package/runner/README.md +25 -9
- package/runner/attach.mjs +69 -30
- package/runner/cawdev.mjs +15 -23
- package/runner/configure.mjs +86 -21
- package/runner/runner.mjs +81 -24
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// R294 — which parts of a runner's config a running daemon can take without a
|
|
2
|
+
// restart, and which it cannot.
|
|
3
|
+
//
|
|
4
|
+
// The daemon reads its config ONCE at boot and threads most of it by value —
|
|
5
|
+
// `projects` and their checkouts, `url`, `name`, the token, the MCP servers.
|
|
6
|
+
// Three keys are the exception: they are read fresh off the live `config`
|
|
7
|
+
// object on every use (a spawn, or the next heartbeat), so a SIGHUP that
|
|
8
|
+
// re-reads the file is enough to apply them. That list was spelled in three
|
|
9
|
+
// places — configure.mjs's header, attach.mjs's `reloadDaemon`, runner.mjs's
|
|
10
|
+
// handler — and nothing pinned that the three agreed. It is spelled HERE and
|
|
11
|
+
// nowhere else now, and the one question everything asks of it is
|
|
12
|
+
// `restartNeededFor`: given what the daemon booted with and what the file says
|
|
13
|
+
// now, which keys would only a restart change?
|
|
14
|
+
//
|
|
15
|
+
// Pure, because the daemon answers it and both config doors repeat the answer,
|
|
16
|
+
// and a function all three share is the only way they cannot disagree.
|
|
17
|
+
//
|
|
18
|
+
// Zero dependencies, like everything in tools/.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The keys runner.mjs's SIGHUP handler applies without a restart.
|
|
22
|
+
*
|
|
23
|
+
* Only settings that are genuinely safe to hot-swap belong here: each one
|
|
24
|
+
* must be read off `config` at the moment it is used, never captured at boot.
|
|
25
|
+
* Adding a key that IS captured would make the daemon claim it applied
|
|
26
|
+
* something it did not, which is worse than saying restart.
|
|
27
|
+
*/
|
|
28
|
+
export const HOT_SWAPPABLE = Object.freeze(['agentCommands', 'grantable', 'acceptsRulesFromConsole']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The legacy spelling of one key, read as the current one — so a config that
|
|
32
|
+
* says `agentCommand: "claude"` is not reported as differing from one that
|
|
33
|
+
* says `agentCommands: ["claude"]`. runner.mjs's `readConfig` makes the same
|
|
34
|
+
* move; this is it applied to a raw file rather than a booted config.
|
|
35
|
+
*/
|
|
36
|
+
export function normaliseConfigFile(file) {
|
|
37
|
+
const { agentCommand, ...rest } = file ?? {};
|
|
38
|
+
if (agentCommand !== undefined && rest.agentCommands === undefined) {
|
|
39
|
+
rest.agentCommands = [agentCommand];
|
|
40
|
+
}
|
|
41
|
+
return rest;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The keys a restart would change: everything that differs between what the
|
|
46
|
+
* daemon booted with and what the file says now, minus {@link HOT_SWAPPABLE}.
|
|
47
|
+
*
|
|
48
|
+
* Compared against the BOOTED file and not the last one read, so an edit
|
|
49
|
+
* somebody reverts by hand clears the notice by itself. Values are compared
|
|
50
|
+
* with a key-sorted stringify, because `{workspaces: [...], name}` and
|
|
51
|
+
* `{name, workspaces: [...]}` are one config. Names only, never values: the
|
|
52
|
+
* answer is shown in a footer and logged, and the token is one of the keys.
|
|
53
|
+
*
|
|
54
|
+
* @returns {string[]} sorted, empty when nothing needs a restart
|
|
55
|
+
*/
|
|
56
|
+
export function restartNeededFor(booted, file) {
|
|
57
|
+
const before = normaliseConfigFile(booted);
|
|
58
|
+
const after = normaliseConfigFile(file);
|
|
59
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
60
|
+
return [...keys]
|
|
61
|
+
.filter((key) => !HOT_SWAPPABLE.includes(key))
|
|
62
|
+
.filter((key) => canonical(before[key]) !== canonical(after[key]))
|
|
63
|
+
.sort();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Applied, or would be on a SIGHUP: the hot-swappable keys that differ. */
|
|
67
|
+
export function reloadableFor(booted, file) {
|
|
68
|
+
const before = normaliseConfigFile(booted);
|
|
69
|
+
const after = normaliseConfigFile(file);
|
|
70
|
+
return HOT_SWAPPABLE.filter((key) => canonical(before[key]) !== canonical(after[key]));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** JSON with object keys in one order, so two spellings of one value agree. */
|
|
74
|
+
function canonical(value) {
|
|
75
|
+
if (value === undefined) return 'undefined';
|
|
76
|
+
return JSON.stringify(value, (_, inner) => (
|
|
77
|
+
inner && typeof inner === 'object' && !Array.isArray(inner)
|
|
78
|
+
? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
|
|
79
|
+
: inner
|
|
80
|
+
));
|
|
81
|
+
}
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -138,15 +138,31 @@ travels beside the secret since this card, and a machine set up before it
|
|
|
138
138
|
falls back to a lookup by label the first time this runs, after which it
|
|
139
139
|
remembers.
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
141
|
+
**What takes effect when** (R294). Three settings a running daemon takes
|
|
142
|
+
without a restart — `agentCommands`, `grantable`, `acceptsRulesFromConsole`,
|
|
143
|
+
the `HOT_SWAPPABLE` list in `tools/lib/runner-config.mjs`, spelled once —
|
|
144
|
+
because the daemon reads them fresh on every use. Everything else in the file
|
|
145
|
+
(`projects` and their workspaces, `url`, `name`, the token, `browser`, the
|
|
146
|
+
tuning) is read once at boot. Every change made by either door ends the same
|
|
147
|
+
way: the running daemon is sent a `SIGHUP` (through the pid the hello names —
|
|
148
|
+
a unix convention asked of a process this operator already owns, not a command
|
|
149
|
+
over the control socket in `attach.mjs`, which is deliberately read-only), it
|
|
150
|
+
re-reads the file, applies what it can, and **says what it could not**: the
|
|
151
|
+
hello carries `restartNeeded`, the keys the file has moved on from since boot
|
|
152
|
+
that only a restart applies, and `configReadAt`. The door waits for that
|
|
153
|
+
re-read and repeats the daemon's own verdict — "applied, no restart needed",
|
|
154
|
+
or "needs a restart to pick up `projects` — q, then cawdev". A daemon too old
|
|
155
|
+
to answer gets the door's own reading of the same function.
|
|
156
|
+
|
|
157
|
+
While `restartNeeded` is non-empty the attached terminal pins a row in its
|
|
158
|
+
footer — `! config changed: projects — restart to apply (q, then cawdev)` — and
|
|
159
|
+
the `c` screen names what is in the file and not yet served ("serves cawdev ·
|
|
160
|
+
dycrypt after a restart", "cawdev: 1, 2 after a restart"). It is compared
|
|
161
|
+
against the file the daemon **booted** with, not the last one it read, so an
|
|
162
|
+
edit reverted by hand clears it. And it is off the file's own mtime, checked
|
|
163
|
+
on every heartbeat: a change made in an editor with nobody signalling anything
|
|
164
|
+
reaches the footer within thirty seconds. A notice, never a restart — the
|
|
165
|
+
daemon says, a person decides.
|
|
150
166
|
|
|
151
167
|
### What it shows you
|
|
152
168
|
|
package/runner/attach.mjs
CHANGED
|
@@ -50,13 +50,14 @@ import { connect } from 'node:net';
|
|
|
50
50
|
import { basename, dirname } from 'node:path';
|
|
51
51
|
import { listSockets, socketPathFor } from './control.mjs';
|
|
52
52
|
import {
|
|
53
|
-
addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
53
|
+
RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
54
54
|
setAcceptsRulesFromConsole, setAgentEnabled, slugsOf,
|
|
55
55
|
} from './configure.mjs';
|
|
56
56
|
import {
|
|
57
57
|
TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
|
|
58
58
|
} from './paths.mjs';
|
|
59
59
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
60
|
+
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
60
61
|
import { oneLine } from './brand.mjs';
|
|
61
62
|
import { Scrollback } from './scrollback.mjs';
|
|
62
63
|
import { Session, signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
@@ -490,6 +491,20 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
490
491
|
|
|
491
492
|
lines.push(settingsBar(runner, runs, width, ink));
|
|
492
493
|
|
|
494
|
+
// R294: the config file has moved on from what this daemon booted with, in
|
|
495
|
+
// ways only a restart applies. A row while it is true and nothing when it
|
|
496
|
+
// is not — the daemon decides, off the file's own mtime, so an edit by hand
|
|
497
|
+
// shows here too and an edit reverted clears it. Pinned, because the one
|
|
498
|
+
// sentence R288 printed about this scrolled off with the next transcript
|
|
499
|
+
// line, and a person who added a project here believed it was being served.
|
|
500
|
+
const stale = Array.isArray(runner?.restartNeeded) ? runner.restartNeeded : [];
|
|
501
|
+
if (stale.length) {
|
|
502
|
+
lines.push(padVisible(clip(
|
|
503
|
+
` ${ink.warn('!')} ${ink.muted('config changed:')} ${ink.text(stale.join(', '))} ${ink.muted(`— restart to apply (${RESTART_FROM_ATTACH})`)}`,
|
|
504
|
+
width,
|
|
505
|
+
), width));
|
|
506
|
+
}
|
|
507
|
+
|
|
493
508
|
// Who and where. The runner's NAME is what the console shows and what
|
|
494
509
|
// `--runner` takes, so it is the word somebody would type; the email is what
|
|
495
510
|
// every action here is done as, and "watching only" is not a lesser state,
|
|
@@ -694,12 +709,27 @@ export function configMenu(file, runner = {}) {
|
|
|
694
709
|
const slugs = slugsOf(file);
|
|
695
710
|
const agents = agentsOf(file);
|
|
696
711
|
const rulesOn = file.acceptsRulesFromConsole === true;
|
|
697
|
-
|
|
712
|
+
// R294: the file and the daemon can disagree, and the row says so rather
|
|
713
|
+
// than claiming the file is what runs. `served` is what the hello says the
|
|
714
|
+
// daemon booted with; a slug in the file and not there is "restart".
|
|
715
|
+
const stale = Array.isArray(runner.restartNeeded) ? runner.restartNeeded : [];
|
|
716
|
+
const served = Array.isArray(runner.projects) ? runner.projects : null;
|
|
717
|
+
const notServed = served && stale.includes('projects') ? slugs.filter((slug) => !served.includes(slug)) : [];
|
|
718
|
+
const serving = slugs.length
|
|
719
|
+
? `serves ${slugs.filter((slug) => !notServed.includes(slug)).join(', ') || 'nothing yet'}`
|
|
720
|
+
: 'serves nothing yet';
|
|
721
|
+
const pending = notServed.length ? ` · ${notServed.join(', ')} after a restart` : '';
|
|
722
|
+
const checkouts = slugs.map((slug) => {
|
|
723
|
+
const count = pathsOf(file.projects[slug]).length;
|
|
724
|
+
const booted = runner.workspaces?.[slug];
|
|
725
|
+
const moved = stale.includes('projects') && typeof booted === 'number' && booted !== count;
|
|
726
|
+
return `${slug}: ${moved ? `${booted}, ${count} after a restart` : count}`;
|
|
727
|
+
}).join(', ');
|
|
698
728
|
return new Select({
|
|
699
729
|
kind: 'config',
|
|
700
730
|
title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
|
|
701
731
|
rows: [
|
|
702
|
-
{ id: 'add-project', label: 'Add a project', hint:
|
|
732
|
+
{ id: 'add-project', label: 'Add a project', hint: `${serving}${pending}` },
|
|
703
733
|
{ id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
|
|
704
734
|
...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
|
|
705
735
|
? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
|
|
@@ -959,6 +989,13 @@ export class Attached {
|
|
|
959
989
|
this.runner = event.runner;
|
|
960
990
|
this.runs = event.runs;
|
|
961
991
|
this.chooseWatched();
|
|
992
|
+
} else if (event.type === 'runner') {
|
|
993
|
+
// R294: the daemon re-read its config — after a signal, or because the
|
|
994
|
+
// file's mtime moved — and says what it applied and what it could not.
|
|
995
|
+
// The hello was sent once, when this terminal attached; this is the
|
|
996
|
+
// same object again, so the footer and the `c` screen read what is
|
|
997
|
+
// true now.
|
|
998
|
+
this.runner = event.runner;
|
|
962
999
|
} else if (event.type === 'runs') {
|
|
963
1000
|
this.runs = event.runs;
|
|
964
1001
|
this.chooseWatched();
|
|
@@ -2125,22 +2162,22 @@ export class Attached {
|
|
|
2125
2162
|
if (!this.requireSignIn('add a project to this machine')) {
|
|
2126
2163
|
return undefined;
|
|
2127
2164
|
}
|
|
2128
|
-
await addProject({
|
|
2165
|
+
const next = await addProject({
|
|
2129
2166
|
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2130
2167
|
askPath: (spec) => this.askPath(spec),
|
|
2131
2168
|
inspect: this.options.inspect ?? inspectPath,
|
|
2132
2169
|
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2133
2170
|
});
|
|
2134
|
-
return this.
|
|
2171
|
+
return this.reloadDaemon(file, next);
|
|
2135
2172
|
}
|
|
2136
2173
|
if (chosen.id === 'add-workspace') {
|
|
2137
|
-
await addWorkspace({
|
|
2174
|
+
const next = await addWorkspace({
|
|
2138
2175
|
configPath, file, url, session: this.session, ask, say, ink, pick,
|
|
2139
2176
|
askPath: (spec) => this.askPath(spec),
|
|
2140
2177
|
inspect: this.options.inspect ?? inspectPath,
|
|
2141
2178
|
clone: (gitUrl, path) => this.cloneInto(gitUrl, path),
|
|
2142
2179
|
});
|
|
2143
|
-
return this.
|
|
2180
|
+
return this.reloadDaemon(file, next);
|
|
2144
2181
|
}
|
|
2145
2182
|
if (chosen.id.startsWith('agent:')) {
|
|
2146
2183
|
const command = chosen.id.slice('agent:'.length);
|
|
@@ -2148,44 +2185,46 @@ export class Attached {
|
|
|
2148
2185
|
file, configPath, ask, say, ink, command, enabled: !agentsOf(file).includes(command),
|
|
2149
2186
|
});
|
|
2150
2187
|
this.runner.agents = agentsOf(next);
|
|
2151
|
-
return this.reloadDaemon(next);
|
|
2188
|
+
return this.reloadDaemon(file, next);
|
|
2152
2189
|
}
|
|
2153
2190
|
if (chosen.id === 'rules') {
|
|
2154
2191
|
const next = await setAcceptsRulesFromConsole({
|
|
2155
2192
|
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
|
2156
2193
|
});
|
|
2157
2194
|
this.runner.acceptsConsoleRules = next.acceptsRulesFromConsole === true;
|
|
2158
|
-
return this.reloadDaemon(next);
|
|
2195
|
+
return this.reloadDaemon(file, next);
|
|
2159
2196
|
}
|
|
2160
2197
|
return this.note('');
|
|
2161
2198
|
}
|
|
2162
2199
|
|
|
2163
2200
|
/**
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2201
|
+
* Every change ends here — R294. The daemon this terminal is attached to is
|
|
2202
|
+
* signalled through the pid the hello carries (R283's `reloadIfRunning`),
|
|
2203
|
+
* and what is printed is the DAEMON's verdict: the keys in
|
|
2204
|
+
* lib/runner-config.mjs's `HOT_SWAPPABLE` were applied, or the file has
|
|
2205
|
+
* moved on in ways only a restart picks up. The daemon pushes its re-read
|
|
2206
|
+
* hello to this terminal as a `runner` event, so `hello` here reads
|
|
2207
|
+
* `this.runner` and waits for `configReadAt` to move — no second socket.
|
|
2208
|
+
*
|
|
2209
|
+
* The footer then carries `restartNeeded` for as long as it is true, which
|
|
2210
|
+
* is the change from R288: a muted line that scrolled away was the only
|
|
2211
|
+
* thing that said a project added here was not being served yet.
|
|
2167
2212
|
*/
|
|
2168
|
-
async reloadDaemon(next) {
|
|
2169
|
-
await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
|
|
2170
|
-
|
|
2213
|
+
async reloadDaemon(file, next) {
|
|
2214
|
+
const verdict = await reloadIfRunning(next.name, (line) => this.say(line), this.ink, {
|
|
2215
|
+
hello: async () => this.runner,
|
|
2216
|
+
fallback: restartNeededFor(file, next),
|
|
2171
2217
|
// Injectable so a test can watch the signal without sending one.
|
|
2172
2218
|
kill: this.options.kill ?? ((pid, signal) => process.kill(pid, signal)),
|
|
2219
|
+
restartHint: RESTART_FROM_ATTACH,
|
|
2220
|
+
waitMs: this.options.reloadWaitMs ?? 3000,
|
|
2173
2221
|
});
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
* the row above look like it took effect. The same sentence `cawdev --setup`
|
|
2181
|
-
* says, because it is the same fact.
|
|
2182
|
-
*/
|
|
2183
|
-
afterProjectsChanged() {
|
|
2184
|
-
const ink = this.ink;
|
|
2185
|
-
this.say(` ${ink.muted('This daemon is still on the config it booted with.')}`);
|
|
2186
|
-
this.say(` ${ink.muted('Press')} ${ink.text('q')} ${ink.muted('to stop it, and the next')} `
|
|
2187
|
-
+ `${ink.text('cawdev')} ${ink.muted('starts one on what you just set up.')}`);
|
|
2188
|
-
return this.note('saved — q, then cawdev, to serve it');
|
|
2222
|
+
if (!verdict.running) {
|
|
2223
|
+
// Reachable when the hello carried no pid — a daemon older than R81.
|
|
2224
|
+
this.say(` ${this.ink.muted('Saved. This daemon is still on the config it booted with — q, then cawdev, to serve it.')}`);
|
|
2225
|
+
return this.note('saved — q, then cawdev, to serve it');
|
|
2226
|
+
}
|
|
2227
|
+
return this.note(verdict.restartNeeded.length ? 'saved — restart to apply' : '');
|
|
2189
2228
|
}
|
|
2190
2229
|
|
|
2191
2230
|
/**
|
package/runner/cawdev.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole,
|
|
33
33
|
setAgentEnabled,
|
|
34
34
|
} from './configure.mjs';
|
|
35
|
+
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
35
36
|
import { Select } from './select.mjs';
|
|
36
37
|
import { storedSession } from './sign-in.mjs';
|
|
37
38
|
import { loadToken } from './token-store.mjs';
|
|
@@ -461,19 +462,25 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
461
462
|
return [id];
|
|
462
463
|
}
|
|
463
464
|
|
|
464
|
-
export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink }) {
|
|
465
|
+
export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink, reloadOptions = {} }) {
|
|
465
466
|
if (verb === 'show') {
|
|
466
467
|
printConfig(file, ink, say);
|
|
467
468
|
return;
|
|
468
469
|
}
|
|
470
|
+
// R294: every change ends the same way — the daemon on this config, if one
|
|
471
|
+
// is running, is signalled and its own verdict is printed. For a project or
|
|
472
|
+
// a workspace that verdict is "needs a restart", because `config.projects`
|
|
473
|
+
// is read once at boot; but it is the daemon saying so about the file it is
|
|
474
|
+
// actually on, not this command guessing about a daemon it cannot see.
|
|
475
|
+
const reload = (next) => reloadIfRunning(file.name, say, ink, {
|
|
476
|
+
fallback: restartNeededFor(file, next), ...reloadOptions,
|
|
477
|
+
});
|
|
469
478
|
if (verb === 'add-project') {
|
|
470
|
-
await addProject({ configPath, file, url, session, ask, say, ink });
|
|
471
|
-
await afterProjectsChanged(file, say, ink);
|
|
479
|
+
await reload(await addProject({ configPath, file, url, session, ask, say, ink }));
|
|
472
480
|
return;
|
|
473
481
|
}
|
|
474
482
|
if (verb === 'add-workspace') {
|
|
475
|
-
await addWorkspace({ configPath, file, url, session, ask, say, ink });
|
|
476
|
-
await afterProjectsChanged(file, say, ink);
|
|
483
|
+
await reload(await addWorkspace({ configPath, file, url, session, ask, say, ink }));
|
|
477
484
|
return;
|
|
478
485
|
}
|
|
479
486
|
if (verb === 'agent') {
|
|
@@ -481,34 +488,19 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
|
|
|
481
488
|
if (!['claude', 'agy'].includes(command)) {
|
|
482
489
|
throw new Error('Name an agent: cawdev config agent enable|disable claude|agy');
|
|
483
490
|
}
|
|
484
|
-
await setAgentEnabled({
|
|
491
|
+
await reload(await setAgentEnabled({
|
|
485
492
|
file, configPath, ask, say, ink, command, enabled: sub === 'enable',
|
|
486
|
-
});
|
|
487
|
-
await reloadIfRunning(file.name, say, ink);
|
|
493
|
+
}));
|
|
488
494
|
return;
|
|
489
495
|
}
|
|
490
496
|
if (verb === 'rules') {
|
|
491
497
|
const [sub] = rest;
|
|
492
|
-
await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' });
|
|
493
|
-
await reloadIfRunning(file.name, say, ink);
|
|
498
|
+
await reload(await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' }));
|
|
494
499
|
return;
|
|
495
500
|
}
|
|
496
501
|
throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
|
|
497
502
|
}
|
|
498
503
|
|
|
499
|
-
/**
|
|
500
|
-
* Adding a project or a workspace is NOT hot-swappable — `config.projects` is
|
|
501
|
-
* read once at boot and threaded everywhere by value, unlike the three
|
|
502
|
-
* settings runner.mjs's SIGHUP handler re-reads. So this only says why
|
|
503
|
-
* nothing happened yet, on the daemon this config would actually restart as.
|
|
504
|
-
*/
|
|
505
|
-
export async function afterProjectsChanged(file, say, ink, { probe = probeSocket } = {}) {
|
|
506
|
-
if (await probe(socketPathFor(file.name))) {
|
|
507
|
-
say(` ${ink.muted('A runner is already running here, on the config it booted with.')}`);
|
|
508
|
-
say(` ${ink.muted('Attach and quit to stop it, and the next cawdev starts one on what you just set up.')}`);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
504
|
/** The config as somebody would want to read it back — never the secret. */
|
|
513
505
|
export function printConfig(file, ink, say) {
|
|
514
506
|
const shown = { ...file };
|
package/runner/configure.mjs
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
// **Every change here writes the FILE and nothing else.** A daemon already
|
|
10
10
|
// running keeps the config it booted with — `cawdev --setup` already says so,
|
|
11
11
|
// and this repeats it rather than inventing a second story. The one exception
|
|
12
|
-
// is `reloadIfRunning`:
|
|
13
|
-
// fresh off the live `config` object on every use in runner.mjs (a
|
|
14
|
-
// the next heartbeat), so a SIGHUP that re-reads the file is enough
|
|
15
|
-
// them without a restart — see runner.mjs's own handler for why that
|
|
16
|
-
// signal and not a command sent over R52's read-only socket.
|
|
12
|
+
// is `reloadIfRunning`: the keys in lib/runner-config.mjs's `HOT_SWAPPABLE`
|
|
13
|
+
// are read fresh off the live `config` object on every use in runner.mjs (a
|
|
14
|
+
// spawn, or the next heartbeat), so a SIGHUP that re-reads the file is enough
|
|
15
|
+
// to apply them without a restart — see runner.mjs's own handler for why that
|
|
16
|
+
// is a signal and not a command sent over R52's read-only socket. R294: every
|
|
17
|
+
// change goes through it, and what it prints is the DAEMON's verdict read off
|
|
18
|
+
// the hello — applied, or these keys need a restart — never this file's guess
|
|
19
|
+
// about which keys the daemon can take.
|
|
17
20
|
//
|
|
18
21
|
// R288: the same functions have a SECOND door — the attached terminal's `c`
|
|
19
22
|
// key — and that is why every question here comes in through a parameter.
|
|
@@ -359,23 +362,28 @@ export async function setAcceptsRulesFromConsole({
|
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
/**
|
|
362
|
-
* The
|
|
365
|
+
* The hello of a daemon running on this exact config, or null.
|
|
363
366
|
*
|
|
364
367
|
* The socket is keyed by NAME (R52), not by config path, so a machine
|
|
365
368
|
* renamed between `--setup` and now would miss its own daemon — an edge case
|
|
366
369
|
* left alone deliberately: naming this wrong points the reload at nobody's
|
|
367
370
|
* process rather than somebody else's.
|
|
371
|
+
*
|
|
372
|
+
* R294: the whole `runner` object and not only its pid, because the hello is
|
|
373
|
+
* where the daemon says what it could not apply (`restartNeeded`) and when it
|
|
374
|
+
* last read the file (`configReadAt`) — the two fields `reloadIfRunning`
|
|
375
|
+
* phrases its answer from.
|
|
368
376
|
*/
|
|
369
|
-
async function
|
|
377
|
+
async function liveHello(name) {
|
|
370
378
|
const path = socketPathFor(name);
|
|
371
379
|
if (!(await probeSocket(path))) return null;
|
|
372
380
|
return new Promise((done) => {
|
|
373
381
|
const client = connectTo(path);
|
|
374
382
|
let buffer = '';
|
|
375
|
-
const finish = (
|
|
383
|
+
const finish = (runner) => {
|
|
376
384
|
clearTimeout(timer);
|
|
377
385
|
try { client.destroy(); } catch { /* already gone */ }
|
|
378
|
-
done(
|
|
386
|
+
done(runner);
|
|
379
387
|
};
|
|
380
388
|
const timer = setTimeout(() => finish(null), 1000);
|
|
381
389
|
client.setEncoding('utf8');
|
|
@@ -384,7 +392,7 @@ async function livePidFor(name) {
|
|
|
384
392
|
const newline = buffer.indexOf('\n');
|
|
385
393
|
if (newline === -1) return;
|
|
386
394
|
try {
|
|
387
|
-
finish(JSON.parse(buffer.slice(0, newline))?.runner
|
|
395
|
+
finish(JSON.parse(buffer.slice(0, newline))?.runner ?? null);
|
|
388
396
|
} catch {
|
|
389
397
|
finish(null);
|
|
390
398
|
}
|
|
@@ -393,20 +401,77 @@ async function livePidFor(name) {
|
|
|
393
401
|
});
|
|
394
402
|
}
|
|
395
403
|
|
|
404
|
+
/** How to restart, said from the shell: attach, quit, start again. */
|
|
405
|
+
export const RESTART_FROM_SHELL = 'cawdev, then q, then cawdev again';
|
|
406
|
+
/** The same, said from inside the attached terminal. */
|
|
407
|
+
export const RESTART_FROM_ATTACH = 'q, then cawdev';
|
|
408
|
+
|
|
396
409
|
/**
|
|
397
|
-
* SIGHUP a daemon already running on this config,
|
|
398
|
-
*
|
|
399
|
-
* command — see this file's header and runner.mjs's handler for
|
|
410
|
+
* SIGHUP a daemon already running on this config, and say what came of it.
|
|
411
|
+
*
|
|
412
|
+
* Never a socket command — see this file's header and runner.mjs's handler for
|
|
413
|
+
* why. R294: the daemon applies what it can (`HOT_SWAPPABLE`) and advertises
|
|
414
|
+
* the rest as `restartNeeded`, so this WAITS for the re-read — `configReadAt`
|
|
415
|
+
* moving past the one the hello carried before the signal — and repeats the
|
|
416
|
+
* daemon's own verdict rather than guessing at it. Three sentences, and each
|
|
417
|
+
* is a fact the daemon stated: applied and nothing else changed; applied, and
|
|
418
|
+
* these keys need a restart; signalled, and nothing came back.
|
|
419
|
+
*
|
|
420
|
+
* @param hello reads the daemon's current hello — the socket by default; the
|
|
421
|
+
* attached terminal hands in its own copy, which the daemon pushes to it
|
|
422
|
+
* @param restartHint how to restart from where the person is standing
|
|
423
|
+
* @param fallback what THIS door believes needs a restart — `restartNeededFor`
|
|
424
|
+
* over the file before and after its own write — used only for a daemon
|
|
425
|
+
* too old to say (no `configReadAt` on its hello). The daemon's answer wins
|
|
426
|
+
* whenever there is one, because the daemon knows what it booted with and
|
|
427
|
+
* the door only knows what it just changed
|
|
428
|
+
* @returns what happened, for a caller that wants to draw it:
|
|
429
|
+
* `{ running, applied, restartNeeded }`
|
|
400
430
|
*/
|
|
401
|
-
export async function reloadIfRunning(name, say, ink = painter(3), {
|
|
402
|
-
|
|
403
|
-
|
|
431
|
+
export async function reloadIfRunning(name, say, ink = painter(3), {
|
|
432
|
+
hello = liveHello,
|
|
433
|
+
kill = process.kill,
|
|
434
|
+
restartHint = RESTART_FROM_SHELL,
|
|
435
|
+
fallback = [],
|
|
436
|
+
waitMs = 3000,
|
|
437
|
+
tick = (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
438
|
+
} = {}) {
|
|
439
|
+
const before = await hello(name);
|
|
440
|
+
if (!before?.pid) return { running: false, applied: false, restartNeeded: [] };
|
|
404
441
|
try {
|
|
405
|
-
kill(pid, 'SIGHUP');
|
|
406
|
-
say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${pid}) to re-read this — no restart needed.`)}`);
|
|
407
|
-
return true;
|
|
442
|
+
kill(before.pid, 'SIGHUP');
|
|
408
443
|
} catch (failure) {
|
|
409
|
-
say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up.`)}`);
|
|
410
|
-
return false;
|
|
444
|
+
say(` ${ink.warn('!')} ${ink.muted(`Could not signal the running daemon (${failure.message}) — restart it to pick this up: ${restartHint}.`)}`);
|
|
445
|
+
return { running: true, applied: false, restartNeeded: [] };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// An older daemon carries no `configReadAt` and cannot be waited for; the
|
|
449
|
+
// most this can say about it is what R283 said.
|
|
450
|
+
let after = null;
|
|
451
|
+
if (before.configReadAt !== undefined) {
|
|
452
|
+
const deadline = Date.now() + waitMs;
|
|
453
|
+
while (Date.now() < deadline) {
|
|
454
|
+
const now = await hello(name);
|
|
455
|
+
if (now?.configReadAt && now.configReadAt !== before.configReadAt) {
|
|
456
|
+
after = now;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
await tick(100);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!after && before.configReadAt !== undefined) {
|
|
464
|
+
say(` ${ink.warn('!')} ${ink.muted(`Signalled the daemon (pid ${before.pid}) but it did not re-read the file — restart it: ${restartHint}.`)}`);
|
|
465
|
+
return { running: true, applied: false, restartNeeded: [] };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const restartNeeded = after
|
|
469
|
+
? (Array.isArray(after.restartNeeded) ? after.restartNeeded : [])
|
|
470
|
+
: fallback;
|
|
471
|
+
if (restartNeeded.length) {
|
|
472
|
+
say(` ${ink.warn('!')} ${ink.muted('Saved. The running daemon (pid')} ${ink.text(String(before.pid))}${ink.muted(') needs a restart to pick up')} ${ink.text(restartNeeded.join(', '))} ${ink.muted(`— ${restartHint}.`)}`);
|
|
473
|
+
} else {
|
|
474
|
+
say(` ${ink.success('✓')} ${ink.muted(`Told the running daemon (pid ${before.pid}) to re-read this — applied, no restart needed.`)}`);
|
|
411
475
|
}
|
|
476
|
+
return { running: true, applied: true, restartNeeded };
|
|
412
477
|
}
|
package/runner/runner.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
|
|
39
39
|
import { findSecret } from '../lib/secrets.mjs';
|
|
40
40
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
41
|
+
import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
|
|
41
42
|
|
|
42
43
|
// --- configuration -----------------------------------------------------------
|
|
43
44
|
|
|
@@ -409,6 +410,14 @@ async function readConfig() {
|
|
|
409
410
|
* daemon's cwd is not the terminal's: `cawdev` starts it detached.
|
|
410
411
|
*/
|
|
411
412
|
configPath: fromFile ? resolve(path) : null,
|
|
413
|
+
/**
|
|
414
|
+
* The file as it was when this daemon booted — R294. What every later
|
|
415
|
+
* reading is compared against, so the daemon can say which of its
|
|
416
|
+
* settings the file has moved on from and only a restart would apply.
|
|
417
|
+
* The RAW file, not `config`: the question is what the operator changed,
|
|
418
|
+
* and defaults filled in here are not something they wrote.
|
|
419
|
+
*/
|
|
420
|
+
bootedFile: fromFile ? file : null,
|
|
412
421
|
};
|
|
413
422
|
|
|
414
423
|
if (!config.token) {
|
|
@@ -7659,6 +7668,11 @@ async function main() {
|
|
|
7659
7668
|
// inventing a file.
|
|
7660
7669
|
agents: [...config.agentCommands],
|
|
7661
7670
|
configPath: config.configPath,
|
|
7671
|
+
// R294: the keys the file has moved on from since boot that only a restart
|
|
7672
|
+
// would apply — nothing, at boot, by definition — and when the file was
|
|
7673
|
+
// last read, so a door that just signalled can wait for a fresh answer.
|
|
7674
|
+
restartNeeded: [],
|
|
7675
|
+
configReadAt: new Date().toISOString(),
|
|
7662
7676
|
// R81. `cawdev` starts a daemon for you when it finds none, and
|
|
7663
7677
|
// quitting the UI leaves it running — it is driving sessions. A
|
|
7664
7678
|
// background process you did not know you started is the cost of that
|
|
@@ -7668,45 +7682,88 @@ async function main() {
|
|
|
7668
7682
|
};
|
|
7669
7683
|
|
|
7670
7684
|
// R283. `cawdev config agent enable/disable` and `cawdev config rules
|
|
7671
|
-
// on/off` write the file and, if a daemon is running on it, send
|
|
7685
|
+
// on/off` write the file and, if a daemon is running on it, send SIGHUP — NOT
|
|
7672
7686
|
// a control-socket command. `control.mjs` is deliberately read-only ("no
|
|
7673
7687
|
// command can" change anything, so a person acting through it never gets
|
|
7674
7688
|
// more than their own HTTPS session already grants); SIGHUP is a unix
|
|
7675
7689
|
// convention for "re-read your config", asked of a process this operator
|
|
7676
7690
|
// already owns, so it does not open that door. Only the settings that are
|
|
7677
|
-
// genuinely safe to hot-swap
|
|
7678
|
-
//
|
|
7679
|
-
//
|
|
7691
|
+
// genuinely safe to hot-swap — `HOT_SWAPPABLE`, in lib/runner-config.mjs,
|
|
7692
|
+
// the ONE spelling of that list — are applied: `agentCommands`, `grantable`
|
|
7693
|
+
// and `acceptsRulesFromConsole` are read fresh from `config` on every use
|
|
7694
|
+
// (spawn, or the next heartbeat), never captured once at boot, so mutating
|
|
7680
7695
|
// them here is enough, with no further plumbing.
|
|
7681
|
-
|
|
7682
|
-
|
|
7696
|
+
//
|
|
7697
|
+
// R294: and the daemon SAYS what it could not apply. Everything else in the
|
|
7698
|
+
// file is read once at boot, and until this a project added to the file was
|
|
7699
|
+
// logged as "nothing changed" — the handler diffed only the three keys it
|
|
7700
|
+
// could swap. `rereadConfig` compares the whole file against the one this
|
|
7701
|
+
// process BOOTED with (not the last one read, so an edit reverted by hand
|
|
7702
|
+
// clears the notice by itself) and publishes the keys only a restart would
|
|
7703
|
+
// apply as `restartNeeded` on the hello, with `configReadAt` so a door that
|
|
7704
|
+
// just signalled can tell a fresh answer from the old one. A notice, never a
|
|
7705
|
+
// restart — R113's rule: the daemon says so, and a person decides. Two
|
|
7706
|
+
// triggers, one function: the signal, and the heartbeat noticing the file's
|
|
7707
|
+
// mtime moved, which is what covers a hand edit with no door at all.
|
|
7708
|
+
let configSeenAt = null;
|
|
7709
|
+
const rereadConfig = async (why) => {
|
|
7710
|
+
const path = config.configPath ?? configPathFromArgv();
|
|
7683
7711
|
try {
|
|
7712
|
+
// The mtime first, so a re-read on a signal is not followed by a second
|
|
7713
|
+
// one on the next beat for the same write.
|
|
7714
|
+
configSeenAt = (await stat(path).catch(() => null))?.mtimeMs ?? configSeenAt;
|
|
7684
7715
|
const file = JSON.parse(await readFile(path, 'utf8'));
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7716
|
+
// What the three keys RESOLVE to, defaults filled in as `readConfig`
|
|
7717
|
+
// fills them, so a key the file omits is not reported as a change.
|
|
7718
|
+
const resolved = {
|
|
7719
|
+
acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
7720
|
+
grantable: file.grantable ?? DEFAULTS.grantable,
|
|
7721
|
+
agentCommands: file.agentCommands
|
|
7722
|
+
?? (file.agentCommand ? [file.agentCommand] : null)
|
|
7723
|
+
?? DEFAULTS.agentCommands,
|
|
7689
7724
|
};
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7725
|
+
const applied = reloadableFor({
|
|
7726
|
+
agentCommands: config.agentCommands,
|
|
7727
|
+
grantable: config.grantable,
|
|
7728
|
+
acceptsRulesFromConsole: config.acceptsRulesFromConsole,
|
|
7729
|
+
}, resolved);
|
|
7730
|
+
Object.assign(config, resolved);
|
|
7696
7731
|
// R288: the hello describes the current state, not the booted one — an
|
|
7697
7732
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
7698
7733
|
// already on. The same object `serveControl` sends, mutated in place.
|
|
7699
7734
|
advertised.acceptsConsoleRules = config.acceptsRulesFromConsole === true;
|
|
7700
7735
|
advertised.agents = [...config.agentCommands];
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
? `
|
|
7705
|
-
|
|
7736
|
+
advertised.restartNeeded = config.bootedFile ? restartNeededFor(config.bootedFile, file) : [];
|
|
7737
|
+
advertised.configReadAt = new Date().toISOString();
|
|
7738
|
+
const said = [
|
|
7739
|
+
applied.length ? `applied: ${applied.join(', ')}` : null,
|
|
7740
|
+
advertised.restartNeeded.length ? `needs restart: ${advertised.restartNeeded.join(', ')}` : null,
|
|
7741
|
+
].filter(Boolean);
|
|
7742
|
+
log(`re-read ${path} on ${why} — ${said.length ? said.join('; ') : 'nothing changed'}`);
|
|
7706
7743
|
} catch (failure) {
|
|
7707
|
-
log(
|
|
7744
|
+
log(`${why} re-read of ${path} failed: ${failure.message}`);
|
|
7708
7745
|
}
|
|
7709
|
-
|
|
7746
|
+
// A terminal already attached got its hello before this; the daemon tells
|
|
7747
|
+
// it rather than waiting for the next attach. An older client ignores an
|
|
7748
|
+
// event type it does not know.
|
|
7749
|
+
control?.publish({ type: 'runner', runner: advertised });
|
|
7750
|
+
};
|
|
7751
|
+
process.on('SIGHUP', () => rereadConfig('SIGHUP'));
|
|
7752
|
+
|
|
7753
|
+
// The heartbeat's half of R294: the file's mtime, read once per beat, and a
|
|
7754
|
+
// re-read when it moved. `stat` and nothing more when it did not, which is
|
|
7755
|
+
// what makes this affordable every thirty seconds. Skipped for a daemon
|
|
7756
|
+
// booted from the environment alone — there is no file to have changed.
|
|
7757
|
+
configSeenAt = config.configPath ? (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null : null;
|
|
7758
|
+
const noticeConfigEdits = async () => {
|
|
7759
|
+
if (!config.configPath) return;
|
|
7760
|
+
const seen = (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null;
|
|
7761
|
+
if (seen === null || seen === configSeenAt) return;
|
|
7762
|
+
// `rereadConfig` records the mtime before it reads: a file caught
|
|
7763
|
+
// mid-write fails to parse, and the write that finishes it moves the
|
|
7764
|
+
// mtime again.
|
|
7765
|
+
await rereadConfig('edit');
|
|
7766
|
+
};
|
|
7710
7767
|
|
|
7711
7768
|
// R52. Nothing here is load-bearing for running an agent, so a daemon that
|
|
7712
7769
|
// cannot open a socket says so and carries on: trading the ability to run
|
|
@@ -7814,7 +7871,7 @@ async function main() {
|
|
|
7814
7871
|
: [];
|
|
7815
7872
|
}
|
|
7816
7873
|
}).catch((failure) => log(`heartbeat failed: ${failure.message}`));
|
|
7817
|
-
await Promise.all([workspacesReported, heartbeatAnswered]);
|
|
7874
|
+
await Promise.all([workspacesReported, heartbeatAnswered, noticeConfigEdits()]);
|
|
7818
7875
|
};
|
|
7819
7876
|
|
|
7820
7877
|
await beat();
|