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/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
- console.log(` ${ink.muted('If your browser did not open, go to')}`);
685
- console.log(` ${ink.accent(url)}`);
686
- console.log(` ${ink.muted('and check it is showing')} ${ink.bold(code)}\n`);
687
- console.log(` ${ink.muted('Waiting…')}`);
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
- console.log(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
700
+ say(` ${ink.success('Signed in')} as ${ink.bold(outcome.email)}.\n`);
691
701
  } else if (outcome.refused) {
692
- console.log(` ${ink.danger('Refused')} in the browser.\n`);
702
+ say(` ${ink.danger('Refused')} in the browser.\n`);
693
703
  } else {
694
- console.log(` ${ink.warn('The code expired.')}\n`);
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
- line.set(commonPrefix(rows.map(([name]) => name)));
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
- this.say(` ${this.ink.text(question)}`);
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
- this.leaveSplit();
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
- // Everything queued goes out first and the footer comes down, so what
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
- const fixed = [...from, ...banner, ...overlay, input];
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(' / ')})` },
@@ -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
- let project = projects[0];
214
- if (projects.length > 1) {
215
- const picked = await pick(ask, say, new Select({
216
- title: 'Which project should this machine also serve?',
217
- rows: projects.map((candidate) => ({
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
- return commands.filter(([name]) => name.slice(1).toLowerCase().startsWith(word));
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
+ }