cawdev-cli 1.0.6-beta → 1.0.8-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/finish-check.mjs +178 -0
- package/lib/roadmap-format.mjs +34 -7
- package/mcp/README.md +7 -2
- package/mcp/server.mjs +88 -11
- package/package.json +1 -1
- package/runner/README.md +83 -4
- package/runner/attach.mjs +83 -23
- package/runner/cawdev.mjs +2 -2
- package/runner/configure.mjs +96 -18
- package/runner/input.mjs +5 -1
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +203 -12
- package/runner/sign-in.mjs +11 -0
package/runner/attach.mjs
CHANGED
|
@@ -482,7 +482,7 @@ export function elapsed(ms) {
|
|
|
482
482
|
*/
|
|
483
483
|
export function footerLines(state, width, ink = painter(3)) {
|
|
484
484
|
const {
|
|
485
|
-
runner, runs = [], email, watching, connected = true,
|
|
485
|
+
runner, runs = [], email, expired = false, watching, connected = true,
|
|
486
486
|
banner = [], overlay = [], input = null, keys = '', status = '',
|
|
487
487
|
running = null, now = Date.now(), matches = [],
|
|
488
488
|
} = state;
|
|
@@ -519,6 +519,16 @@ export function footerLines(state, width, ink = painter(3)) {
|
|
|
519
519
|
), width));
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
// The platform refused the stored session (401). Pinned for the same reason
|
|
523
|
+
// as the row above: one line in the scrollback is gone with the next
|
|
524
|
+
// transcript line, and every action from here on would fail without it.
|
|
525
|
+
if (expired) {
|
|
526
|
+
lines.push(padVisible(clip(
|
|
527
|
+
` ${ink.warn('!')} ${ink.muted('session expired —')} ${ink.text('/login')} ${ink.muted('to sign in again')}`,
|
|
528
|
+
width,
|
|
529
|
+
), width));
|
|
530
|
+
}
|
|
531
|
+
|
|
522
532
|
// Who and where. The runner's NAME is what the console shows and what
|
|
523
533
|
// `--runner` takes, so it is the word somebody would type; the email is what
|
|
524
534
|
// every action here is done as, and "watching only" is not a lesser state,
|
|
@@ -679,19 +689,19 @@ async function openSession(argv, url) {
|
|
|
679
689
|
* too is not belt and braces: a machine over ssh has no browser to open, and
|
|
680
690
|
* this is the only thing that makes it work at all.
|
|
681
691
|
*/
|
|
682
|
-
async function runSignIn(session, ink = painter()) {
|
|
692
|
+
async function runSignIn(session, ink = painter(), say = (line) => console.log(line)) {
|
|
683
693
|
const outcome = await signInThroughBrowser(session, ({ url, code }) => {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
694
|
+
say(` ${ink.muted('If your browser did not open, go to')}`);
|
|
695
|
+
say(` ${ink.accent(url)}`);
|
|
696
|
+
say(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
|
|
697
|
+
say(` ${ink.muted('Waiting…')}`);
|
|
688
698
|
});
|
|
689
699
|
if (outcome.signedIn) {
|
|
690
|
-
|
|
700
|
+
say(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
|
|
691
701
|
} else if (outcome.refused) {
|
|
692
|
-
|
|
702
|
+
say(` ${ink.danger('Refused')} in the browser.\n`);
|
|
693
703
|
} else {
|
|
694
|
-
|
|
704
|
+
say(` ${ink.warn('The code expired.')}\n`);
|
|
695
705
|
}
|
|
696
706
|
return outcome;
|
|
697
707
|
}
|
|
@@ -755,7 +765,7 @@ export function configMenu(file, runner = {}, gone = new Set()) {
|
|
|
755
765
|
kind: 'config',
|
|
756
766
|
title: `this machine — ${runner.name ?? file.name ?? 'cawdev'}`,
|
|
757
767
|
rows: [
|
|
758
|
-
{ id: 'add-project', label: 'Add a project', hint: `${serving}${pending}` },
|
|
768
|
+
{ id: 'add-project', label: 'Add a project — or create one on cawdev', hint: `${serving}${pending}` },
|
|
759
769
|
{ id: 'add-workspace', label: 'Add a workspace to a project', hint: checkouts || 'no projects to add one to' },
|
|
760
770
|
...[...new Set([...KNOWN_AGENTS, ...agents])].map((command) => (agents.includes(command)
|
|
761
771
|
? { id: `agent:${command}`, label: `Disable ${command}`, hint: 'spawned here now' }
|
|
@@ -801,6 +811,9 @@ function refOf(word) {
|
|
|
801
811
|
return word.replace(/^r/i, 'R').replace(/^i/i, 'i');
|
|
802
812
|
}
|
|
803
813
|
|
|
814
|
+
/** How many matching commands advanced mode's prompt shows at once. */
|
|
815
|
+
const SPLIT_COMPLETIONS = 6;
|
|
816
|
+
|
|
804
817
|
/** The slash commands, and the one place they are described. */
|
|
805
818
|
const COMMANDS = [
|
|
806
819
|
['/help', 'this list'],
|
|
@@ -887,6 +900,11 @@ export class Attached {
|
|
|
887
900
|
constructor(socketPath, session, options = {}) {
|
|
888
901
|
this.socketPath = socketPath;
|
|
889
902
|
this.session = session;
|
|
903
|
+
if (session) {
|
|
904
|
+
session.onExpired = () => this.sessionExpired();
|
|
905
|
+
}
|
|
906
|
+
/** True from a 401 on a session that was signed in until `/login` works. */
|
|
907
|
+
this.expired = false;
|
|
890
908
|
this.options = options;
|
|
891
909
|
this.ink = painter();
|
|
892
910
|
this.screen = new Scrollback(options.out ?? process.stdout);
|
|
@@ -1864,8 +1882,16 @@ export class Attached {
|
|
|
1864
1882
|
}
|
|
1865
1883
|
if (rows.length) {
|
|
1866
1884
|
// As far as they agree, which is what every shell does and nobody has
|
|
1867
|
-
// to be taught. One match completes it whole.
|
|
1868
|
-
|
|
1885
|
+
// to be taught. One match completes it whole. Once the line is already
|
|
1886
|
+
// that far, Tab moves down the list instead — `/log` is a command AND
|
|
1887
|
+
// the prefix of `/login` and `/logout`, and Tab stopping there for
|
|
1888
|
+
// good left those two reachable only by the arrows.
|
|
1889
|
+
const prefix = commonPrefix(rows.map(([name]) => name));
|
|
1890
|
+
if (rows.length > 1 && line.text.toLowerCase() === prefix.toLowerCase() && this.input.showing !== false) {
|
|
1891
|
+
this.input.at = (at + 1) % rows.length;
|
|
1892
|
+
} else {
|
|
1893
|
+
line.set(prefix);
|
|
1894
|
+
}
|
|
1869
1895
|
this.input.showing = true;
|
|
1870
1896
|
this.dirty = true;
|
|
1871
1897
|
}
|
|
@@ -2606,7 +2632,7 @@ export class Attached {
|
|
|
2606
2632
|
* comes back absolute; configure.mjs inspects it again before acting.
|
|
2607
2633
|
*/
|
|
2608
2634
|
async askPath({ question, suggestions, slug, gitUrl, inspect = inspectPath }) {
|
|
2609
|
-
|
|
2635
|
+
// The question is the picker's title; printing it as well put it on screen twice.
|
|
2610
2636
|
const rows = [];
|
|
2611
2637
|
for (const each of suggestions) {
|
|
2612
2638
|
const seen = await inspect(each.path, { gitUrl });
|
|
@@ -2829,21 +2855,31 @@ export class Attached {
|
|
|
2829
2855
|
* the moment it is decided.
|
|
2830
2856
|
*/
|
|
2831
2857
|
async signIn() {
|
|
2858
|
+
let say;
|
|
2832
2859
|
if (this.split) {
|
|
2833
|
-
|
|
2860
|
+
// Advanced mode stays: the URL and the code go to the prompt window,
|
|
2861
|
+
// where everything said there is read, and the browser opens anyway.
|
|
2862
|
+
say = (line) => this.say(line);
|
|
2863
|
+
} else {
|
|
2864
|
+
// Everything queued goes out first and the footer comes down, so what
|
|
2865
|
+
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2866
|
+
// than through the middle of a live region nothing is going to erase.
|
|
2867
|
+
this.say('');
|
|
2868
|
+
this.screen.update(this.pending, []);
|
|
2869
|
+
this.pending = [];
|
|
2834
2870
|
}
|
|
2835
|
-
|
|
2836
|
-
// `runSignIn` prints with `console.log` lands under the transcript rather
|
|
2837
|
-
// than through the middle of a live region nothing is going to erase.
|
|
2838
|
-
this.say('');
|
|
2839
|
-
this.screen.update(this.pending, []);
|
|
2840
|
-
this.pending = [];
|
|
2841
|
-
const outcome = await runSignIn(this.session, this.ink)
|
|
2871
|
+
const outcome = await runSignIn(this.session, this.ink, say)
|
|
2842
2872
|
.catch((failure) => ({ signedIn: false, message: failure.message }));
|
|
2843
2873
|
if (outcome.message) {
|
|
2844
2874
|
this.say(this.ink.danger(` could not sign in: ${outcome.message}`));
|
|
2845
2875
|
}
|
|
2846
|
-
if (this.session.signedIn) {
|
|
2876
|
+
if (this.session.signedIn && this.expired) {
|
|
2877
|
+
// After an expiry the watchers never stopped — they have been failing
|
|
2878
|
+
// quietly and pick up on their own — so a second set would poll twice.
|
|
2879
|
+
this.expired = false;
|
|
2880
|
+
this.note('signed in again');
|
|
2881
|
+
void this.findStopped();
|
|
2882
|
+
} else if (this.session.signedIn) {
|
|
2847
2883
|
void this.watchInbox();
|
|
2848
2884
|
void this.watchQuestions();
|
|
2849
2885
|
void this.findStopped();
|
|
@@ -2852,6 +2888,20 @@ export class Attached {
|
|
|
2852
2888
|
return undefined;
|
|
2853
2889
|
}
|
|
2854
2890
|
|
|
2891
|
+
/**
|
|
2892
|
+
* The platform answered 401 to a session that was signed in: it expired, was
|
|
2893
|
+
* signed out from the console, or the account went. Said once, where it is
|
|
2894
|
+
* read — the prompt window in advanced mode, the scrollback otherwise — and
|
|
2895
|
+
* the footer keeps a row until `/login` succeeds. The watchers keep polling
|
|
2896
|
+
* and fail quietly; they pick up again on their own after the sign-in.
|
|
2897
|
+
*/
|
|
2898
|
+
sessionExpired() {
|
|
2899
|
+
if (this.expired) return;
|
|
2900
|
+
this.expired = true;
|
|
2901
|
+
this.say(this.ink.warn(' your cawdev session has expired or is no longer valid — type /login to sign in again'));
|
|
2902
|
+
this.note('session expired — /login to sign in again');
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2855
2905
|
// --- advanced mode — split.mjs ----------------------------------------------
|
|
2856
2906
|
|
|
2857
2907
|
/**
|
|
@@ -3219,7 +3269,16 @@ export class Attached {
|
|
|
3219
3269
|
+ ' · / commands · tab next window');
|
|
3220
3270
|
// A question or a request on screen says which window it came from.
|
|
3221
3271
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3222
|
-
|
|
3272
|
+
// The commands matching what is typed, above the line as in the footer —
|
|
3273
|
+
// advanced mode drew none, so enter took a highlighted command nobody saw.
|
|
3274
|
+
const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
|
|
3275
|
+
// Six rows at most, scrolled to the highlighted one: a bare `/` matches
|
|
3276
|
+
// every command, and the prompt grows with what it holds.
|
|
3277
|
+
const first = Math.max(0, Math.min(matches.at - 2, matches.rows.length - SPLIT_COMPLETIONS));
|
|
3278
|
+
const completing = completionLines(
|
|
3279
|
+
matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
|
|
3280
|
+
);
|
|
3281
|
+
const fixed = [...from, ...banner, ...overlay, ...completing, input];
|
|
3223
3282
|
const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
|
|
3224
3283
|
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3225
3284
|
// more than a third of the screen, and never so tall that no window fits
|
|
@@ -4561,6 +4620,7 @@ export class Attached {
|
|
|
4561
4620
|
runner: this.runner,
|
|
4562
4621
|
runs: this.runs,
|
|
4563
4622
|
email: this.session.email,
|
|
4623
|
+
expired: this.expired,
|
|
4564
4624
|
watching: run,
|
|
4565
4625
|
connected: this.connected,
|
|
4566
4626
|
// R83's status line is about what this machine is DRIVING, which is not
|
package/runner/cawdev.mjs
CHANGED
|
@@ -60,7 +60,7 @@ const USAGE = `
|
|
|
60
60
|
|
|
61
61
|
cawdev config a menu: add a project, add a
|
|
62
62
|
workspace, agents, console rules
|
|
63
|
-
cawdev config add-project serve one more project you can write to
|
|
63
|
+
cawdev config add-project serve one more project you can write to, or create one
|
|
64
64
|
cawdev config add-workspace another checkout for a project you
|
|
65
65
|
already serve — R47's concurrency
|
|
66
66
|
cawdev config remove-project <slug> stop serving a project (its checkouts
|
|
@@ -464,7 +464,7 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
464
464
|
const picked = await pick(ask, say, new Select({
|
|
465
465
|
title: 'What do you want to change on this machine?',
|
|
466
466
|
rows: [
|
|
467
|
-
{ id: 'add-project', label: 'Add a project' },
|
|
467
|
+
{ id: 'add-project', label: 'Add a project — or create one on cawdev' },
|
|
468
468
|
{ id: 'add-workspace', label: 'Add a workspace to a project' },
|
|
469
469
|
{ id: 'remove-project', label: 'Stop serving a project' },
|
|
470
470
|
{ id: 'agent-enable', label: `Enable an agent (${KNOWN_AGENTS.join(' / ')})` },
|
package/runner/configure.mjs
CHANGED
|
@@ -181,13 +181,22 @@ async function settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspec
|
|
|
181
181
|
await cloneAside(ask, clone, gitUrl, path);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/** The row of Add a project that makes a new project rather than picking one. */
|
|
185
|
+
export const CREATE_A_PROJECT = 'create:new';
|
|
186
|
+
|
|
184
187
|
/**
|
|
185
|
-
* Add one project this session can serve to an already-configured machine
|
|
188
|
+
* Add one project this session can serve to an already-configured machine —
|
|
189
|
+
* or CREATE one on cawdev and serve it, from the same list.
|
|
186
190
|
*
|
|
187
191
|
* Clones it, then widens the existing token's grants to cover it — via
|
|
188
192
|
* {@link resolveTokenId}, or by minting a fresh token when that comes back
|
|
189
193
|
* empty (an older machine's first run through this command, which is also
|
|
190
194
|
* what teaches it the id for next time).
|
|
195
|
+
*
|
|
196
|
+
* The list is ALWAYS shown, one project or six: going straight to "where
|
|
197
|
+
* should cawdev live?" when one was left answered a question nobody had been
|
|
198
|
+
* asked, about a project the screen never named. Its last row is "Create a
|
|
199
|
+
* new project on cawdev", see {@link askForANewProject}.
|
|
191
200
|
*/
|
|
192
201
|
export async function addProject({
|
|
193
202
|
configPath, file, url, session: given = null,
|
|
@@ -206,30 +215,37 @@ export async function addProject({
|
|
|
206
215
|
const already = new Set(slugsOf(file));
|
|
207
216
|
const projects = servable(await session.request('/api/projects'))
|
|
208
217
|
.filter((project) => !already.has(project.slug));
|
|
209
|
-
if (!projects.length) {
|
|
210
|
-
throw new Error('Every project you can serve is already configured on this machine.');
|
|
211
|
-
}
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
const picked = await pick(ask, say, new Select({
|
|
220
|
+
title: projects.length
|
|
221
|
+
? 'Which project should this machine also serve?'
|
|
222
|
+
: 'Every project you can serve is already here — create a new one?',
|
|
223
|
+
rows: [
|
|
224
|
+
...projects.map((candidate) => ({
|
|
218
225
|
id: candidate.slug, label: candidate.name, hint: candidate.slug,
|
|
219
226
|
})),
|
|
227
|
+
{ id: CREATE_A_PROJECT, label: 'Create a new project on cawdev', hint: `on ${url} — a name and its git repository` },
|
|
228
|
+
],
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
let project;
|
|
232
|
+
let path;
|
|
233
|
+
if (picked.row.id === CREATE_A_PROJECT) {
|
|
234
|
+
({ project, path } = await askForANewProject({
|
|
235
|
+
file, url, session, ask, say, ink, pick, askPath, inspect, clone,
|
|
220
236
|
}));
|
|
237
|
+
} else {
|
|
221
238
|
project = projects.find((candidate) => candidate.slug === picked.row.id);
|
|
239
|
+
path = await askPath({
|
|
240
|
+
ask, say, pick, inspect,
|
|
241
|
+
question: `Where should ${project.slug} live on this machine?`,
|
|
242
|
+
suggestions: suggestProjectPaths(file, project.slug),
|
|
243
|
+
slug: project.slug,
|
|
244
|
+
gitUrl: project.gitUrl ?? null,
|
|
245
|
+
});
|
|
246
|
+
await settleCheckout({ path, slug: project.slug, gitUrl: project.gitUrl ?? null, ask, say, ink, clone, inspect });
|
|
222
247
|
}
|
|
223
248
|
|
|
224
|
-
const path = await askPath({
|
|
225
|
-
ask, say, pick, inspect,
|
|
226
|
-
question: `Where should ${project.slug} live on this machine?`,
|
|
227
|
-
suggestions: suggestProjectPaths(file, project.slug),
|
|
228
|
-
slug: project.slug,
|
|
229
|
-
gitUrl: project.gitUrl ?? null,
|
|
230
|
-
});
|
|
231
|
-
await settleCheckout({ path, slug: project.slug, gitUrl: project.gitUrl ?? null, ask, say, ink, clone, inspect });
|
|
232
|
-
|
|
233
249
|
const grants = {};
|
|
234
250
|
for (const slug of [...already, project.slug]) {
|
|
235
251
|
grants[slug] = ['runner:operate'];
|
|
@@ -250,9 +266,71 @@ export async function addProject({
|
|
|
250
266
|
|
|
251
267
|
await write(configPath, next);
|
|
252
268
|
say(` ${ink.success('✓')} ${ink.muted('Now serving')} ${ink.text(project.slug)} ${ink.muted('at')} ${ink.accent(path)}`);
|
|
269
|
+
if (project.created) {
|
|
270
|
+
// The rest of the console's setup walk — rules, members, the brief — is
|
|
271
|
+
// decisions about the project, not about this machine, and lives there.
|
|
272
|
+
say(` ${ink.muted('The rest of its setup (rules, members, the CTO Interview):')} ${ink.accent(`${url}/projects/${project.slug}/setup`)}`);
|
|
273
|
+
}
|
|
253
274
|
return next;
|
|
254
275
|
}
|
|
255
276
|
|
|
277
|
+
/**
|
|
278
|
+
* "Create a new project on cawdev": a name, the repository, the checkout —
|
|
279
|
+
* and only then the project, so a clone that fails leaves nothing on the
|
|
280
|
+
* platform. The slug is the SERVER's (R290, `<username>-<name>`); it is shown
|
|
281
|
+
* before anything is made and the answer to the POST is what is used.
|
|
282
|
+
*
|
|
283
|
+
* An empty git URL is allowed when the path is already a checkout: its
|
|
284
|
+
* `origin` is the repository, which is the commonest case — somebody with the
|
|
285
|
+
* code on their disk who wants cawdev to know about it.
|
|
286
|
+
*/
|
|
287
|
+
async function askForANewProject({ file, url, session, ask, say, ink, pick, askPath, inspect, clone }) {
|
|
288
|
+
const name = String(await ask.line(' Name of the new project ')).trim();
|
|
289
|
+
if (!name) {
|
|
290
|
+
throw new Error('A project needs a name. Nothing changed.');
|
|
291
|
+
}
|
|
292
|
+
const suggested = await session.request(`/api/projects/slug-suggestion?name=${encodeURIComponent(name)}`)
|
|
293
|
+
.then((answer) => answer?.slug)
|
|
294
|
+
.catch(() => null);
|
|
295
|
+
const slug = suggested || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
|
296
|
+
say(` ${ink.muted('It will be')} ${ink.text(slug)} ${ink.muted('on')} ${ink.accent(url)}`);
|
|
297
|
+
|
|
298
|
+
const typedUrl = String(await ask.line(
|
|
299
|
+
' Its git repository URL — blank if the checkout is already on this machine ',
|
|
300
|
+
)).trim();
|
|
301
|
+
const path = await askPath({
|
|
302
|
+
ask, say, pick, inspect,
|
|
303
|
+
question: `Where should ${slug} live on this machine?`,
|
|
304
|
+
suggestions: suggestProjectPaths(file, slug),
|
|
305
|
+
slug,
|
|
306
|
+
gitUrl: typedUrl || null,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
let gitUrl = typedUrl || null;
|
|
310
|
+
if (!gitUrl) {
|
|
311
|
+
const seen = await inspect(path, { gitUrl: null });
|
|
312
|
+
if (seen.kind !== 'checkout' || !seen.remote) {
|
|
313
|
+
throw new Error(`${path} is ${describe(seen, slug)} — with no git URL, the path must be a checkout `
|
|
314
|
+
+ 'with an origin. Nothing changed.');
|
|
315
|
+
}
|
|
316
|
+
gitUrl = seen.remote;
|
|
317
|
+
say(` ${ink.muted('Its repository, from that checkout\'s origin:')} ${ink.accent(gitUrl)}`);
|
|
318
|
+
}
|
|
319
|
+
await settleCheckout({ path, slug, gitUrl, ask, say, ink, clone, inspect });
|
|
320
|
+
|
|
321
|
+
if (!await confirm(ask, `Create ${name} on ${url}`)) {
|
|
322
|
+
throw new Error(`Nothing created on cawdev; the checkout at ${path} stays.`);
|
|
323
|
+
}
|
|
324
|
+
let created;
|
|
325
|
+
try {
|
|
326
|
+
created = await session.request('/api/projects', { method: 'POST', body: { name, gitUrl } });
|
|
327
|
+
} catch (failure) {
|
|
328
|
+
throw new Error(`cawdev refused to create ${name}: ${failure.message}. The checkout at ${path} stays.`);
|
|
329
|
+
}
|
|
330
|
+
say(` ${ink.success('✓')} ${ink.muted('Created')} ${ink.text(created.name ?? name)} ${ink.muted('as')} ${ink.text(created.slug)}`);
|
|
331
|
+
return { project: { ...created, created: true }, path };
|
|
332
|
+
}
|
|
333
|
+
|
|
256
334
|
/** Add another checkout for a project this machine already serves — R47. */
|
|
257
335
|
export async function addWorkspace({
|
|
258
336
|
configPath, file,
|
package/runner/input.mjs
CHANGED
|
@@ -192,7 +192,11 @@ export function completions(text, commands) {
|
|
|
192
192
|
return [];
|
|
193
193
|
}
|
|
194
194
|
const word = typed.slice(1).toLowerCase();
|
|
195
|
-
|
|
195
|
+
const found = commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
|
|
196
|
+
// A name typed in full comes first, so enter runs `/log` and not `/login`,
|
|
197
|
+
// the first longer one in the list.
|
|
198
|
+
const exact = found.filter(([name]) => name.slice(1).toLowerCase() === word);
|
|
199
|
+
return [...exact, ...found.filter((row) => !exact.includes(row))];
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
/**
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// What a daemon that restarted under its sessions does about them — R339.
|
|
2
|
+
//
|
|
3
|
+
// A daemon killed mid-run (most often for memory, by the kernel, while the
|
|
4
|
+
// session ran a test suite) comes back healthy with an empty `running` map.
|
|
5
|
+
// The platform could only infer what happened: five minutes later
|
|
6
|
+
// StaleRunSweeper failed the run as "its runner is alive but stopped driving
|
|
7
|
+
// it", a sentence that names no cause and arrives after somebody has already
|
|
8
|
+
// gone looking. The one process that KNOWS is the new daemon, so the old one
|
|
9
|
+
// leaves a note: the runs it is driving, written beside the socket every time
|
|
10
|
+
// that set changes. On boot the new one reads it and reports each run it no
|
|
11
|
+
// longer holds as DAEMON_RESTARTED, at once.
|
|
12
|
+
//
|
|
13
|
+
// And so it happens less: `sessionMemoryMax` spawns each agent in a systemd
|
|
14
|
+
// scope with a memory ceiling, so a runaway test suite takes down its own
|
|
15
|
+
// session — the ordinary "agent was terminated" path — and not the daemon.
|
|
16
|
+
//
|
|
17
|
+
// Pure where it can be. The file, the scope probe and the kill are the only
|
|
18
|
+
// side effects, and each takes what it touches as an argument.
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { basename, dirname, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a runner's note lives — one per runner id, because one machine may
|
|
27
|
+
* run two configs. NOT in the run directory, which holds sockets and nothing
|
|
28
|
+
* else (the terminal lists it to find daemons): beside it, so a test that
|
|
29
|
+
* points CAWDEV_RUN_DIR at a temporary directory keeps its notes there too.
|
|
30
|
+
*/
|
|
31
|
+
export function drivingPath(runnerId, env = process.env) {
|
|
32
|
+
const dir = env.CAWDEV_DRIVING_DIR
|
|
33
|
+
?? (env.CAWDEV_RUN_DIR ? `${env.CAWDEV_RUN_DIR}-driving` : join(homedir(), '.cawdev', 'driving'));
|
|
34
|
+
return join(dir, `${runnerId}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The note: who wrote it and what it was driving. `command` is the child's
|
|
39
|
+
* command's base name, so the next daemon can tell its own orphan from an
|
|
40
|
+
* unrelated process that was handed the same pid after a reboot.
|
|
41
|
+
*/
|
|
42
|
+
export function snapshotOf(pid, running) {
|
|
43
|
+
const runs = [];
|
|
44
|
+
for (const [id, child] of running) {
|
|
45
|
+
runs.push({
|
|
46
|
+
id,
|
|
47
|
+
projectSlug: child?.cawdevProjectSlug ?? null,
|
|
48
|
+
childPid: Number.isInteger(child?.pid) ? child.pid : null,
|
|
49
|
+
command: child?.cawdevCommand ? basename(child.cawdevCommand) : null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { pid, runs };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Atomic: a daemon killed mid-write must not leave half a note for the next one. */
|
|
56
|
+
export async function writeDriving(path, snapshot) {
|
|
57
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
59
|
+
await writeFile(temporary, JSON.stringify(snapshot), { mode: 0o600 });
|
|
60
|
+
await rename(temporary, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The previous note, or null — a missing or unreadable one is "nothing was being driven". */
|
|
64
|
+
export async function readDriving(path) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
67
|
+
return parsed && Number.isInteger(parsed.pid) && Array.isArray(parsed.runs) ? parsed : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The runs a previous daemon left behind, or none.
|
|
75
|
+
*
|
|
76
|
+
* None when the note is this process's own, and none when its writer is
|
|
77
|
+
* still alive: that is a second daemon on the same runner id, and its runs
|
|
78
|
+
* are its own business.
|
|
79
|
+
*/
|
|
80
|
+
export function leftBehind(previous, myPid, isAlive) {
|
|
81
|
+
if (!previous || previous.pid === myPid || isAlive(previous.pid)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return previous.runs.filter((run) => typeof run.id === 'string' && run.projectSlug);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a pid is a live process. Signal 0 tests without sending. */
|
|
88
|
+
export function isAlive(pid) {
|
|
89
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (failure) {
|
|
94
|
+
return failure.code === 'EPERM';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Live states a restart does not concern: they hold no process. */
|
|
99
|
+
const NOT_DRIVEN = new Set(['QUEUED', 'PAUSED', 'USAGE_LIMITED']);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the platform's view of a run means a restart killed it: live,
|
|
103
|
+
* holding a process, and still this runner's. A run carried on elsewhere in
|
|
104
|
+
* the meantime is not ours to fail.
|
|
105
|
+
*/
|
|
106
|
+
export function killedByRestart(run, runnerId) {
|
|
107
|
+
return Boolean(run?.live)
|
|
108
|
+
&& !NOT_DRIVEN.has(run.state)
|
|
109
|
+
&& (!run.runnerId || run.runnerId === runnerId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What the run says — the cause, the evidence to look for, and the way out.
|
|
114
|
+
* `stopped` is the signal a daemon that stopped ON PURPOSE wrote into its
|
|
115
|
+
* note; without one it was killed, and memory is the usual reason.
|
|
116
|
+
*/
|
|
117
|
+
export function restartSummary({ name, previousPid, pid, orphanStopped, stopped = null }) {
|
|
118
|
+
const why = stopped
|
|
119
|
+
? `It was stopped (${stopped}) — by somebody, or by the service manager restarting it.`
|
|
120
|
+
: 'Most often the machine ran out of memory — `journalctl -k | grep -i "killed process"` '
|
|
121
|
+
+ 'says so on Linux; `sessionMemoryMax` in the runner config keeps a session from taking '
|
|
122
|
+
+ 'the daemon with it.';
|
|
123
|
+
return `The cawdev daemon on ${name} restarted while driving this run (it was pid `
|
|
124
|
+
+ `${previousPid}; it is pid ${pid} now), and the agent session went with it. ${why}`
|
|
125
|
+
+ (orphanStopped ? ' The session it left running unattended has been stopped.' : '')
|
|
126
|
+
+ ' The workspace is kept with whatever the session had written: carry it on there, or '
|
|
127
|
+
+ 'discard it.';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- the memory ceiling ----------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `sessionMemoryMax` as systemd reads it — `6G`, `512M`, `75%` — or null.
|
|
134
|
+
* Anything else is null, and the daemon says so rather than guessing.
|
|
135
|
+
*/
|
|
136
|
+
export function memoryMaxOf(value) {
|
|
137
|
+
if (value === undefined || value === null || value === '') return null;
|
|
138
|
+
const text = String(value).trim();
|
|
139
|
+
return /^\d+(\.\d+)?[KMGT]?$|^\d+(\.\d+)?%$/i.test(text) ? text.toUpperCase() : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The command line that runs `command args` under the ceiling — or the same
|
|
144
|
+
* command line, when there is no ceiling to apply. `systemd-run --scope` execs
|
|
145
|
+
* the command in place, so the pid, the process group and the pipes are the
|
|
146
|
+
* agent's own: cancelling (`kill(-pid)`) and the stream readers do not change.
|
|
147
|
+
*/
|
|
148
|
+
export function scoped(command, args, scope) {
|
|
149
|
+
if (!scope?.memoryMax) {
|
|
150
|
+
return { command, args };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
command: 'systemd-run',
|
|
154
|
+
args: [`--${scope.manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
|
|
155
|
+
'-p', `MemoryMax=${scope.memoryMax}`, '--', command, ...args],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether this machine can apply the ceiling: Linux, and a systemd that will
|
|
161
|
+
* make a scope for this user. Tried with `true` once at boot, because a
|
|
162
|
+
* daemon run as a system service has no user manager and every spawn would
|
|
163
|
+
* otherwise fail on a command that cannot work here.
|
|
164
|
+
*/
|
|
165
|
+
export async function probeScope(memoryMax, { platform = process.platform, run = runQuietly } = {}) {
|
|
166
|
+
if (!memoryMax) return { scope: null, why: null };
|
|
167
|
+
if (platform !== 'linux') {
|
|
168
|
+
return { scope: null, why: `sessionMemoryMax needs Linux and systemd; this is ${platform}` };
|
|
169
|
+
}
|
|
170
|
+
for (const manager of ['user', 'system']) {
|
|
171
|
+
const code = await run('systemd-run',
|
|
172
|
+
[`--${manager}`, '--scope', '--quiet', '--collect', '--no-ask-password',
|
|
173
|
+
'-p', `MemoryMax=${memoryMax}`, 'true']);
|
|
174
|
+
if (code === 0) {
|
|
175
|
+
return { scope: { manager, memoryMax }, why: null };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
scope: null,
|
|
180
|
+
why: 'systemd-run could not make a scope here (no user manager — `loginctl enable-linger` '
|
|
181
|
+
+ 'for this user, or run the daemon as root)',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function runQuietly(command, args) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const child = spawn(command, args, { stdio: 'ignore' });
|
|
188
|
+
child.on('error', () => resolve(-1));
|
|
189
|
+
child.on('close', (code) => resolve(code));
|
|
190
|
+
});
|
|
191
|
+
}
|