@north-light/crouter 0.3.158 → 0.3.159

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.
@@ -27,7 +27,8 @@ import { profilesStateBlock } from '../core/profiles/state-block.js';
27
27
  import { stateBlock } from '../core/help.js';
28
28
  import { fuzzyMatch } from '../core/canvas/browse/model.js';
29
29
  import { loadPins, savePins } from '../core/canvas/browse/pins.js';
30
- import { backgroundRunningBashJobs, formatBashElapsed, jobStartedAtMs } from '../core/bash-jobs.js';
30
+ import { existsSync, writeFileSync } from 'node:fs';
31
+ import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../core/bash-jobs.js';
31
32
  // The API seam — every canvas mutation/read routes through the daemon (B-1).
32
33
  import { cliClient, cliCanvasSource, ApiCanvasSource, rethrowAsCliError, getNodeOrNull } from './api-client.js';
33
34
  // Past this much context, an ORCHESTRATOR that spawns a managed child is better
@@ -842,16 +843,153 @@ const nodeBashBackground = defineLeaf({
842
843
  return `Handed ${result['backgrounded']} bash command(s) to the background job system for ${result['node_id']} · ran ${formatBashElapsed(oldestElapsedMs)} in the foreground.`;
843
844
  },
844
845
  });
846
+ const nodeBashList = defineLeaf({
847
+ name: 'list',
848
+ description: 'list a node’s live background bash jobs',
849
+ whenToUse: 'the Inspector’s jobs section reads this to show what a node handed off to the background',
850
+ tier: 'hidden',
851
+ help: {
852
+ name: 'node bash list',
853
+ summary: 'read every still-running background bash job owned by one node',
854
+ params: [
855
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose background jobs to list. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
856
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
857
+ { kind: 'flag', name: 'tail', type: 'int', required: false, default: 0, constraint: 'Include this many trailing log lines per job (0 = none).' },
858
+ ],
859
+ output: [
860
+ { name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
861
+ { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
862
+ ],
863
+ outputKind: 'object',
864
+ effects: ['Reads the node context jobs directory. Changes nothing.'],
865
+ },
866
+ run: async (input) => {
867
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
868
+ let id = input['node'];
869
+ if (id === undefined || id === '')
870
+ id = nodeInPane(pane);
871
+ if (id === undefined || id === '')
872
+ id = process.env['CRTR_NODE_ID'];
873
+ if (id === undefined || id === '') {
874
+ throw new InputError({ error: 'no_node', message: 'no node found for the bash job list', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
875
+ }
876
+ const node = await getNodeOrNull(id);
877
+ if (node === null) {
878
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
879
+ }
880
+ const now = Date.now();
881
+ const tail = Math.max(0, Math.trunc(Number(input['tail'] ?? 0)));
882
+ const dir = contextDir(node.node_id);
883
+ const jobs = activeBackgroundBashJobs(dir).map((job) => ({
884
+ job_id: job.jobId,
885
+ command: job.command,
886
+ started_at: new Date(job.startedAtMs).toISOString(),
887
+ elapsed_ms: now - job.startedAtMs,
888
+ log_path: job.logPath,
889
+ pgid: readJobPgid(dir, job.jobId) ?? null,
890
+ log_tail: tailJobLog(job.logPath, tail),
891
+ }));
892
+ return { node_id: node.node_id, jobs };
893
+ },
894
+ render: (result) => {
895
+ const jobs = result['jobs'];
896
+ if (jobs.length === 0)
897
+ return `No background bash jobs for ${result['node_id']}.`;
898
+ return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
899
+ },
900
+ });
901
+ /** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
902
+ * false only when the group was already gone at the first signal. */
903
+ function stopJobGroup(pgid) {
904
+ try {
905
+ process.kill(-pgid, 'SIGTERM');
906
+ }
907
+ catch {
908
+ return false;
909
+ }
910
+ return true;
911
+ }
912
+ const nodeBashKill = defineLeaf({
913
+ name: 'kill',
914
+ description: 'stop one of a node’s background bash jobs',
915
+ whenToUse: 'a human watching the Inspector wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
916
+ tier: 'hidden',
917
+ help: {
918
+ name: 'node bash kill',
919
+ summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
920
+ params: [
921
+ { kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
922
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
923
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
924
+ ],
925
+ output: [
926
+ { name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
927
+ { name: 'job_id', type: 'string', required: true, constraint: 'The job that was stopped.' },
928
+ { name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
929
+ ],
930
+ outputKind: 'object',
931
+ effects: [
932
+ 'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
933
+ 'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was canceled — which steers that node mid-turn.',
934
+ ],
935
+ },
936
+ run: async (input) => {
937
+ const pane = input['pane'] ?? process.env['TMUX_PANE'];
938
+ let id = input['node'];
939
+ if (id === undefined || id === '')
940
+ id = nodeInPane(pane);
941
+ if (id === undefined || id === '')
942
+ id = process.env['CRTR_NODE_ID'];
943
+ if (id === undefined || id === '') {
944
+ throw new InputError({ error: 'no_node', message: 'no node found for the bash job', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
945
+ }
946
+ const node = await getNodeOrNull(id);
947
+ if (node === null) {
948
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
949
+ }
950
+ const jobId = input['job'];
951
+ const dir = contextDir(node.node_id);
952
+ const paths = bashJobPaths(dir, jobId);
953
+ if (!existsSync(paths.dir)) {
954
+ throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
955
+ }
956
+ const pgid = readJobPgid(dir, jobId);
957
+ if (pgid === undefined) {
958
+ throw new InputError({
959
+ error: 'not_stoppable',
960
+ message: `job ${jobId} recorded no process group`,
961
+ next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
962
+ });
963
+ }
964
+ const signaled = stopJobGroup(pgid);
965
+ // The supervisor shares the group it leads, so it usually dies with the
966
+ // command and never writes job.exit. Record the cancellation ourselves —
967
+ // that sentinel is what retires the job from every live-jobs read.
968
+ await new Promise((r) => setTimeout(r, 400));
969
+ try {
970
+ process.kill(-pgid, 'SIGKILL');
971
+ }
972
+ catch { /* already gone */ }
973
+ if (!existsSync(paths.jobExit))
974
+ writeFileSync(paths.jobExit, '143\n');
975
+ await cliClient().sendMessage(node.node_id, {
976
+ body: `Background bash job ${jobId} was canceled from the Inspector before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
977
+ tier: 'urgent',
978
+ });
979
+ return { node_id: node.node_id, job_id: jobId, signaled };
980
+ },
981
+ render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
982
+ });
845
983
  const nodeBash = defineBranch({
846
984
  name: 'bash',
847
985
  description: 'control file-backed bash jobs owned by a node',
848
- whenToUse: 'the tmux prefix menu needs to hand a running bash command off without waiting for the automatic timeout',
986
+ whenToUse: 'a running bash command must be handed off, listed, or stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
849
987
  tier: 'hidden',
850
988
  help: {
851
989
  name: 'node bash',
852
990
  summary: 'control file-backed bash jobs owned by a node',
853
991
  },
854
- children: [nodeBashBackground],
992
+ children: [nodeBashBackground, nodeBashList, nodeBashKill],
855
993
  });
856
994
  export const surfaceNodeIdLeaf = defineLeaf({
857
995
  name: 'id',
@@ -8,17 +8,18 @@ import { runCoreView } from '../core/tui/host.js';
8
8
  export const surfaceInspectLeaf = defineLeaf({
9
9
  name: 'inspect',
10
10
  description: 'host the native Inspector for one node',
11
- whenToUse: 'you want a live native screen for a node’s overview, raw record, and owner-scoped crons. Use node inspect show for the underlying one-shot data read instead.',
11
+ whenToUse: 'you want a live native screen for a node’s overview, raw record, owner-scoped crons, and live background bash jobs. Use node inspect show for the underlying one-shot data read instead.',
12
12
  help: {
13
13
  name: 'surface inspect',
14
14
  summary: 'host the native Inspector in this pane (the attach viewer opens it as a tmux popup)',
15
+ // sections: overview · schedule (armed crons) · jobs (live background bash) · raw
15
16
  params: [
16
17
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id or resolvable reference to inspect.' },
17
- { kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
18
+ { kind: 'flag', name: 'section', type: 'enum', choices: ['overview', 'schedule', 'jobs', 'raw'], required: false, default: 'overview', constraint: 'Section focused when the Inspector opens.' },
18
19
  ],
19
20
  output: [],
20
21
  outputKind: 'object',
21
- effects: ['Reads node and cron data through crtr subprocesses and crtrd. Takes over the hosting pane (or popup) until it quits.'],
22
+ effects: ['Reads node, cron, and background bash job data through crtr subprocesses and crtrd. Takes over the hosting pane (or popup) until it quits.'],
22
23
  },
23
24
  run: async (input) => {
24
25
  const node = input['node'];
@@ -75,7 +75,7 @@ async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
75
75
  await wait(stepMs);
76
76
  }
77
77
  }
78
- test('coalesce inlines short reports with their absolute path and only hints for remaining canonical refs', () => {
78
+ test('coalesce inlines short report bodies with a trailing artifact pointer and only hints for remaining canonical refs', () => {
79
79
  freshNode('child');
80
80
  const child = createNode({
81
81
  node_id: 'child',
@@ -93,8 +93,10 @@ test('coalesce inlines short reports with their absolute path and only hints for
93
93
  const digest = coalesce([
94
94
  finalizeInboxEntry({ from: 'child', tier: 'normal', kind: 'update', label: 'short report', ref: 'child:reports/short-update.md' }),
95
95
  ]);
96
- assert.match(digest, new RegExp(`\\[update\\] short report at ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:`));
97
- assert.match(digest, /short report body/);
96
+ // The label is NOT reprinted above the body it duplicates; the path trails it.
97
+ assert.doesNotMatch(digest, /\[update\] short report/);
98
+ assert.match(digest, /\[update\]\n {4}short report body\n/);
99
+ assert.match(digest, new RegExp(`\\(report: ${reportPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
98
100
  assert.doesNotMatch(digest, /Dereference a ref with/);
99
101
  const guidance = 'Dereference a ref with `crtr canvas history read <ref>`.';
100
102
  const canonical = coalesce([
@@ -5,9 +5,20 @@ export interface BashJobPaths {
5
5
  jobLog: string;
6
6
  jobExit: string;
7
7
  jobBg: string;
8
+ /** Process-group id of the job's detached supervisor, written at spawn. It is
9
+ * what makes a job stoppable from outside the agent that started it (the
10
+ * Inspector's cancel) — without it on disk, only the agent's own handoff
11
+ * notice knows the group to signal. */
12
+ jobPgid: string;
8
13
  }
9
14
  export declare function bashJobsDir(contextDir: string): string;
10
15
  export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
16
+ /** The job's supervisor process group, or undefined for a job started before
17
+ * pgid was persisted (or a partially-written dir). */
18
+ export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
19
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
20
+ * rather than the whole file, which for a long-running job can be enormous. */
21
+ export declare function tailJobLog(logPath: string, count: number): string[];
11
22
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
12
23
  * valve calls this instead of building the literal inline so the epoch-ms
13
24
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -3,7 +3,7 @@
3
3
  // the prefix menu requests backgrounding by creating job.bg. Keeping that signal
4
4
  // on disk means the command can outlive its pi process and still report its exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  export function bashJobsDir(contextDir) {
9
9
  return join(contextDir, 'jobs');
@@ -17,8 +17,48 @@ export function bashJobPaths(contextDir, jobId) {
17
17
  jobLog: join(dir, 'job.log'),
18
18
  jobExit: join(dir, 'job.exit'),
19
19
  jobBg: join(dir, 'job.bg'),
20
+ jobPgid: join(dir, 'job.pgid'),
20
21
  };
21
22
  }
23
+ /** The job's supervisor process group, or undefined for a job started before
24
+ * pgid was persisted (or a partially-written dir). */
25
+ export function readJobPgid(contextDir, jobId) {
26
+ try {
27
+ const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobPgid, 'utf8').trim());
28
+ return Number.isInteger(n) && n > 1 ? n : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
35
+ * rather than the whole file, which for a long-running job can be enormous. */
36
+ export function tailJobLog(logPath, count) {
37
+ if (count <= 0)
38
+ return [];
39
+ const WINDOW = 64 * 1024;
40
+ let fd;
41
+ try {
42
+ const size = statSync(logPath).size;
43
+ const start = Math.max(0, size - WINDOW);
44
+ const buf = Buffer.alloc(Math.min(size, WINDOW));
45
+ fd = openSync(logPath, 'r');
46
+ readSync(fd, buf, 0, buf.length, start);
47
+ const text = buf.toString('utf8');
48
+ // A partial first line (we cut mid-line) is dropped, not shown truncated.
49
+ const lines = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
50
+ if (lines[lines.length - 1] === '')
51
+ lines.pop();
52
+ return lines.slice(-count);
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ finally {
58
+ if (fd !== undefined)
59
+ closeSync(fd);
60
+ }
61
+ }
22
62
  /** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
23
63
  * valve calls this instead of building the literal inline so the epoch-ms
24
64
  * prefix is a real contract shared with its parser (jobStartedAtMs), not an
@@ -75,9 +75,9 @@ export declare function clipBody(body: string): {
75
75
  *
76
76
  * Format (per sender group):
77
77
  * From <sender> — <N> update(s):
78
- * [<kind>] <label> at <absolute report path>:
79
- *
80
- * <short report body>
78
+ * [<kind>]
79
+ * <short report body>
80
+ * (report: <absolute report path>)
81
81
  *
82
82
  * Large reports remain canonical pointers and add one shared dereference hint.
83
83
  */
@@ -330,15 +330,19 @@ function inlineReport(e) {
330
330
  /**
331
331
  * Render one entry's digest line(s).
332
332
  *
333
- * Short pushed reports are inlined with their absolute report path. Larger
334
- * reports stay canonical history pointers. Direct messages retain their bounded
333
+ * A short pushed report is inlined as its own body, followed by a pointer to
334
+ * the durable artifact. The label is NOT repeated above it: a report's label is
335
+ * just its first line (feed.ts `firstLine`), so printing both reads as a
336
+ * truncated preview stacked on the full text it previews. Larger reports stay
337
+ * canonical history pointers. Direct messages retain their bounded
335
338
  * inline-body/spill behavior.
336
339
  */
337
340
  function renderEntry(e) {
338
341
  const report = inlineReport(e);
339
342
  if (report !== null) {
343
+ const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
340
344
  return {
341
- text: ` [${e.kind}] ${e.label} at ${report.path}:\n\n${report.body}`,
345
+ text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
342
346
  hasCanonicalRef: false,
343
347
  };
344
348
  }
@@ -364,9 +368,9 @@ function renderEntry(e) {
364
368
  *
365
369
  * Format (per sender group):
366
370
  * From <sender> — <N> update(s):
367
- * [<kind>] <label> at <absolute report path>:
368
- *
369
- * <short report body>
371
+ * [<kind>]
372
+ * <short report body>
373
+ * (report: <absolute report path>)
370
374
  *
371
375
  * Large reports remain canonical pointers and add one shared dereference hint.
372
376
  */
@@ -1,6 +1,14 @@
1
1
  import type { CronDTO } from '../../api/dto/crons.js';
2
2
  import type { SourceError, ViewCore } from '../view/contract.js';
3
- import { scheduledItems, type InspectorSection } from './model.js';
3
+ import { scheduledItems, type BashJobRow, type InspectorSection } from './model.js';
4
+ /** What a `y` confirmation will act on. Both sections arm cancellation the same
5
+ * way, so the pending action carries its own kind rather than the view guessing
6
+ * from whichever section happens to be open when the key lands. */
7
+ export type PendingAction = {
8
+ kind: 'cron' | 'job';
9
+ id: string;
10
+ label: string;
11
+ };
4
12
  export interface InspectorState {
5
13
  target: string;
6
14
  section: InspectorSection;
@@ -14,10 +22,17 @@ export interface InspectorState {
14
22
  error: SourceError | null;
15
23
  fetchedAt: number;
16
24
  };
25
+ jobs: {
26
+ data: BashJobRow[] | null;
27
+ error: SourceError | null;
28
+ fetchedAt: number;
29
+ };
17
30
  selectedCronId: string | null;
31
+ selectedJobId: string | null;
18
32
  expanded: boolean;
19
- pendingCancel: string | null;
33
+ pendingCancel: PendingAction | null;
20
34
  scroll: Record<InspectorSection, number>;
21
35
  }
22
36
  export declare const inspectorCore: ViewCore<InspectorState>;
37
+ export declare function selectedJob(state: InspectorState): BashJobRow | undefined;
23
38
  export { scheduledItems };