@volter/supercode-teams 0.3.1 → 0.3.3

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/bin/teams.mjs CHANGED
@@ -20,6 +20,9 @@
20
20
  // attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>
21
21
  // watch [--interval ms] [--for ms]
22
22
  // launches get <key> | cancel <key> | list operators: keyed receipts; cancel only unissued keys
23
+ // health [status|features|alarms] [--json] [--all] the machine's vitals (the machine-health pack)
24
+ // health subscribe|unsubscribe <address> operators: alarm mail for a session or board address
25
+ // health run | once | config this machine only: the pack's own probe verbs
23
26
  // rpc <harness.v1 method> [json-params] operators: one harness.v1 request
24
27
  // log verify
25
28
  //
@@ -28,6 +31,7 @@
28
31
  // CLI in ./workspace.mjs.
29
32
  import { createWriteStream, readFileSync, writeFileSync } from 'node:fs';
30
33
  import { spawnSync } from 'node:child_process';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
35
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
32
36
 
33
37
  // A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
@@ -45,9 +49,9 @@ import { moveWorkspace } from '../workspace-move.mjs';
45
49
  import { teamsHome, paths, readJson, AccountLog, parseHostPort } from '@volter/teams/home';
46
50
  import { NODE_PROTOCOL } from '@volter/teams/channel';
47
51
 
48
- const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail']);
52
+ const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
49
53
  // Every flag a machine verb reads; any other is refused rather than silently ignored.
50
- const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids']);
54
+ const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids', 'json', 'all']);
51
55
 
52
56
  const argv = process.argv.slice(2);
53
57
  const flags = {};
@@ -65,7 +69,7 @@ for (let i = 0; i < argv.length; i += 1) {
65
69
  }
66
70
  const [verb, sub, ...rest] = positional;
67
71
 
68
- const MACHINE_HELP = 'machine verbs: machine start|status, describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, browser (add --machine NAME for an enrolled machine)';
72
+ const MACHINE_HELP = 'machine verbs: machine start|status, describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, health, browser (add --machine NAME for an enrolled machine)';
69
73
 
70
74
  if (verb === undefined || verb === 'help') {
71
75
  process.stdout.write(`supercode teams: ${MACHINE_HELP}\n`);
@@ -133,6 +137,15 @@ async function withNode(fn) {
133
137
  }
134
138
  }
135
139
 
140
+ /** The machine-health pack's CLI: an explicit path, the installed package, else a source checkout's sibling. */
141
+ async function healthPackEntry() {
142
+ const { existsSync } = await import('node:fs');
143
+ if (process.env.SUPERCODE_HEALTH_ENTRY) return existsSync(process.env.SUPERCODE_HEALTH_ENTRY) ? process.env.SUPERCODE_HEALTH_ENTRY : null;
144
+ try { const found = fileURLToPath(import.meta.resolve('@volter/supercode-health/bin')); if (existsSync(found)) return found; } catch { /* not installed */ }
145
+ const sibling = fileURLToPath(new URL('../../health/bin/health.mjs', import.meta.url));
146
+ return existsSync(sibling) ? sibling : null;
147
+ }
148
+
136
149
  async function main() {
137
150
  switch (verb) {
138
151
  case 'machine': {
@@ -329,6 +342,34 @@ async function main() {
329
342
  die('launches get <key> | cancel <key> | list [--machine NAME]');
330
343
  return;
331
344
  }
345
+ case 'health': {
346
+ // This machine's vitals are read from the pack's own files, not through the daemon: the reading has to
347
+ // work when the daemon is what is sick. `--machine NAME` reads an enrolled machine through its doors.
348
+ const local = !flags.machine;
349
+ if (local || ['run', 'once', 'config'].includes(sub)) {
350
+ if (!local) die(`health ${sub} runs on the machine itself; read another machine with health status --machine NAME`);
351
+ const entry = await healthPackEntry();
352
+ if (!entry) die('the machine-health pack (@volter/supercode-health) is not installed; set SUPERCODE_HEALTH_ENTRY to its bin/health.mjs', 1);
353
+ const r = spawnSync(process.execPath, [entry, ...(sub ? [sub] : []), ...rest, ...(flags.json ? ['--json'] : []), ...(flags.all ? ['--all'] : [])], { stdio: 'inherit' });
354
+ process.exit(r.status ?? 1);
355
+ }
356
+ if (sub === 'subscribe' || sub === 'unsubscribe') {
357
+ if (!rest[0]) die(`health ${sub} <sc:machine:harness:id> --machine NAME`);
358
+ return withNode(async (rpc) => out(await rpc.call(`health.${sub}`, { address: rest[0] })));
359
+ }
360
+ if (sub && !['status', 'features', 'alarms'].includes(sub)) die('health [status|features|alarms] [--machine NAME] | health subscribe|unsubscribe <address> | health run|once|config');
361
+ return withNode(async (rpc) => {
362
+ const answer = await rpc.call('health.read', {});
363
+ const s = answer.snapshot;
364
+ const entry = await healthPackEntry();
365
+ const fmt = entry ? await import(new URL('../lib/format.mjs', pathToFileURL(entry)).href).catch(() => null) : null;
366
+ if (flags.json || !fmt) { out(sub === 'features' ? s?.features ?? null : sub === 'alarms' ? s?.alarms ?? null : answer); return; }
367
+ if (sub === 'features') { out(s ? fmt.formatFeatures(s.features) : 'no reading yet'); return; }
368
+ if (sub === 'alarms') { out(s?.alarms?.length ? s.alarms.map((a) => `${a.level.toUpperCase()} ${a.key}: ${a.summary} (since ${a.since})`).join('\n') : 'no alarms raised'); return; }
369
+ out(fmt.formatStatus(s, { all: Boolean(flags.all) }));
370
+ if (!answer.probe?.running) out(`probe: not run by that machine's daemon (${answer.probe?.reason ?? 'unknown'})`);
371
+ });
372
+ }
332
373
  case 'rpc': {
333
374
  const channel = await machineChannel();
334
375
  const client = new SupercodeHarnessClient({ transport: harnessTransport(channel) });
package/bin/workspace.mjs CHANGED
@@ -485,6 +485,9 @@ async function sessionsCommand(sub, command) {
485
485
  if (sub === 'url') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions url REF'); const descriptor = await session.descriptor(); return output({ version: 1, context: scope(context), data: { url: `${context.origin}/teams/${context.team_id}/sessions/${descriptor.data.id}` } }); }
486
486
  // A session view (RH2 ADR 0008): its owner lets a workplace Room's Conversation read this session with the tokens that
487
487
  // workplace signs. `bind` records it and prints what the workplace's own bind door takes; `unbind` removes it.
488
+ // The custodian's read of a session's view bindings, each with where a Room notice to it would be delivered now
489
+ // (ready, stopped and resumable, ended, unknown liveness, or unavailable with the failing predicate).
490
+ if (sub === 'bindings') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions bindings REF'); return output(await command.client.request('GET', `${session.path}/view-bindings`)); }
488
491
  if (sub === 'bind' || sub === 'unbind') {
489
492
  only(flags, ['json', 'help', 'context', 'room', 'agent', 'conversation', 'rh2']); requireArgs(rest, 1, 1, `sessions ${sub} REF --room ROOM --agent PRINCIPAL --rh2 ORIGIN`);
490
493
  if (!flags.room || (!flags.agent && !flags.conversation) || (flags.agent && flags.conversation) || !flags.rh2) usage(`sessions ${sub} needs --room, --agent (or a legacy --conversation), and --rh2 (the workplace's origin)`);
@@ -994,6 +997,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
994
997
  const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
995
998
  const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() }, onTurn: (turn) => connector.noteTurn(turn), onTurnWithdrawn: (turn) => connector.withdrawTurn(turn) });
996
999
  machine.machineEvents = machineEvents;
1000
+ // The machine-health probe mails as this enrolled machine (`sc:<machine>:operator:machine-health`).
1001
+ machine.healthMachine = saved.machine.name;
997
1002
  connector.closeAgentSession=async id=>{
998
1003
  const channel=await openLocalMachine(paths(daemonHome).socket);
999
1004
  try{const rpc=await nodeRpc(channel);return await rpc.call('harness.v1.agents.closeSession',{closure:id},{timeoutMs:60000});}
package/machine.mjs CHANGED
@@ -35,7 +35,7 @@ import { Workspaces, inspectCheckout } from './workspaces.mjs';
35
35
  import { WorkspaceContexts, fromContext } from '@volter/teams/context';
36
36
  import { TeamsClient } from '@volter/teams/http-client';
37
37
  import { supercodeHome } from '@volter/teams/home';
38
- import { readMailHistory } from './mail-history.mjs';
38
+ import { readMailHistory, machineName } from './mail-history.mjs';
39
39
  import { randomBytes, createHash } from 'node:crypto';
40
40
  import { WebSocket } from 'ws';
41
41
  import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
@@ -259,10 +259,45 @@ export class TeamsMachine extends MachineCore {
259
259
 
260
260
  async afterListen() {
261
261
  this.#startMailWatch();
262
+ this.#startHealth();
262
263
  if(Object.keys(this.inputRepairs).length)this.#watchInputRepairs();
263
264
  }
264
265
 
266
+ // ---- the machine-health pack (docs/adr/0006-machine-health-pack.md) -------
267
+ // An enrolled machine runs the optional pack's probe as a child, so every connected machine is measured.
268
+ // The pack holds one probe per OS user (exit 3: another holds it; retried a minute later in case that one
269
+ // stops). SUPERCODE_HEALTH=0 turns it off. The probe only reads and mails; this daemon serves its readings.
270
+ #startHealth() {
271
+ this.healthStopped = false;
272
+ if (!this.link || (this.env.SUPERCODE_HEALTH ?? '1') === '0') { this.healthRun = { running: false, reason: this.link ? 'turned off (SUPERCODE_HEALTH=0)' : 'not an enrolled connector' }; return; }
273
+ const entry = healthEntry(this.env);
274
+ if (!entry) { this.healthRun = { running: false, reason: 'the machine-health pack (@volter/supercode-health) is not installed; set SUPERCODE_HEALTH_ENTRY to its bin/health.mjs' }; return; }
275
+ const start = () => {
276
+ if (this.healthStopped) return;
277
+ const child = spawn(process.execPath, [entry, 'run', ...(this.healthMachine ? ['--machine', this.healthMachine] : [])], { env: { ...this.env, SUPERCODE_BIN: this.supercodeBin }, stdio: 'ignore', windowsHide: true });
278
+ this.healthChild = child;
279
+ this.healthRun = { running: true, pid: child.pid, entry, since: Date.now() };
280
+ child.once('error', (error) => { this.healthRun = { running: false, reason: error.message, entry }; });
281
+ child.once('exit', (code, signal) => {
282
+ if (this.healthChild === child) this.healthChild = null;
283
+ if (this.healthStopped) return;
284
+ const held = code === 3;
285
+ this.healthBackoff = held ? 60_000 : Math.min(300_000, (this.healthBackoff ?? 2500) * 2);
286
+ this.healthRun = { running: false, reason: held ? 'another probe holds this user\'s pidfile' : `exited (${signal ?? code}); restarting`, entry, exitedAt: Date.now() };
287
+ this.healthTimer = setTimeout(start, this.healthBackoff); this.healthTimer.unref?.();
288
+ });
289
+ };
290
+ start();
291
+ }
292
+
293
+ #stopHealth() {
294
+ this.healthStopped = true;
295
+ clearTimeout(this.healthTimer);
296
+ this.healthChild?.kill('SIGTERM');
297
+ }
298
+
265
299
  async beforeClose() {
300
+ this.#stopHealth();
266
301
  clearInterval(this.terminalMailTimer); await this.terminalMailWork;
267
302
  clearInterval(this.inputRepairTimer);this.inputRepairTimer=null;
268
303
  await this.inputRepairWork;
@@ -520,7 +555,7 @@ export class TeamsMachine extends MachineCore {
520
555
  }
521
556
 
522
557
  #noticeTarget(address){
523
- return sessionNoticeTarget(address,{machine:hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),
558
+ return sessionNoticeTarget(address,{machine:machineName(this.env),
524
559
  mail:request=>this.#nativeMailReply(request),
525
560
  discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
526
561
  recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
@@ -1528,6 +1563,34 @@ export class TeamsMachine extends MachineCore {
1528
1563
  if (!this.machineEvents || params.team_id!==this.machineEvents.teamId) throw new Error('this connector is not serving that team');
1529
1564
  return method==='machine.events.events'?this.machineEvents.snapshot():this.machineEvents.subscribe(params.address,method.endsWith('.unsubscribe'));
1530
1565
  }
1566
+ case 'health.read': {
1567
+ // A machine's vitals: operators, and principals granted view on the whole machine.
1568
+ if (!operator) need('view', '*');
1569
+ const dir = healthDirOf(this.env);
1570
+ let snapshot = null;
1571
+ try { snapshot = JSON.parse(readFileSync(join(dir, 'latest.json'), 'utf8')); } catch { /* no reading yet */ }
1572
+ let subscriptions = [];
1573
+ try { subscriptions = JSON.parse(readFileSync(join(dir, 'subscriptions.json'), 'utf8')).addresses ?? []; } catch { /* none */ }
1574
+ return { snapshot, probe: this.healthRun ?? { running: false, reason: 'not started by this daemon' }, subscriptions, dir };
1575
+ }
1576
+ case 'health.subscribe':
1577
+ case 'health.unsubscribe': {
1578
+ // Alarm mail for a session or board address. Operators only: it decides who is woken.
1579
+ if (!operator) throw Object.assign(new Error('subscribing to machine health requires operator authority'), { code: 403 });
1580
+ const address = String(params.address ?? '');
1581
+ if (!/^sc:[^:\s]+:[^:\s]+:\S+$/.test(address)) throw new Error('health.subscribe needs a full sc:<machine>:<harness>:<id> address');
1582
+ const dir = healthDirOf(this.env);
1583
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1584
+ const file = join(dir, 'subscriptions.json');
1585
+ let addresses = [];
1586
+ try { addresses = JSON.parse(readFileSync(file, 'utf8')).addresses ?? []; } catch { /* first */ }
1587
+ addresses = addresses.filter((a) => a !== address);
1588
+ if (method === 'health.subscribe') addresses.push(address);
1589
+ writeFileSync(`${file}.${process.pid}.tmp`, JSON.stringify({ addresses }) + '\n', { mode: 0o600 });
1590
+ renameSync(`${file}.${process.pid}.tmp`, file);
1591
+ act(method, address);
1592
+ return { addresses };
1593
+ }
1531
1594
  case 'harness.v1.agents.bindSession': {
1532
1595
  if (!operator) throw Object.assign(new Error('binding an agent session requires operator authority'),{code:403});
1533
1596
  const {session,context}=params;
@@ -1575,7 +1638,7 @@ export class TeamsMachine extends MachineCore {
1575
1638
  const ended=await this.#serveRequest('harness.v1.runtimes.close',{connection:runtime.connection});
1576
1639
  result=ended?.closed?{state:'closed',reason:'native runtime close receipt'}:{state:'unknown',reason:'runtime returned no close receipt'};
1577
1640
  }else{
1578
- const machine=hostname().split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-'),address=`sc:${machine}:${closure.harness}:${closure.native_id}`;
1641
+ const machine=machineName(this.env),address=`sc:${machine}:${closure.harness}:${closure.native_id}`;
1579
1642
  const code=await new Promise((resolve,reject)=>{
1580
1643
  const child=spawn(this.supercodeBin,['close',address],{env:{...this.env,SUPERCODE_CALLER:address},stdio:'ignore'});
1581
1644
  const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},30000);
@@ -2293,3 +2356,17 @@ function alive(pid) {
2293
2356
  }
2294
2357
 
2295
2358
  export { listAnnouncedPorts };
2359
+
2360
+ /** The machine-health pack's state directory: the pack's own rule (sdk/health/lib/paths.mjs). */
2361
+ function healthDirOf(env) {
2362
+ if (env.SUPERCODE_HEALTH_DIR) return env.SUPERCODE_HEALTH_DIR;
2363
+ return join(env.SUPERCODE_HOME || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode'), 'health');
2364
+ }
2365
+
2366
+ /** The pack's probe entry: an explicit path, the installed package, else a source checkout's sibling. */
2367
+ function healthEntry(env) {
2368
+ if (env.SUPERCODE_HEALTH_ENTRY) return existsSync(env.SUPERCODE_HEALTH_ENTRY) ? env.SUPERCODE_HEALTH_ENTRY : null;
2369
+ try { const found = fileURLToPath(import.meta.resolve('@volter/supercode-health/bin')); if (existsSync(found)) return found; } catch { /* not installed */ }
2370
+ const sibling = fileURLToPath(new URL('../health/bin/health.mjs', import.meta.url));
2371
+ return existsSync(sibling) ? sibling : null;
2372
+ }
package/mail-history.mjs CHANGED
@@ -71,7 +71,7 @@ async function answeredMail(rows, binary, env, signal) {
71
71
 
72
72
  // Match the CLI's local_machine_name: the native mailbox belongs to its
73
73
  // enrolled context, which can differ from a viewer connector's --home.
74
- function machineName(env) {
74
+ export function machineName(env) {
75
75
  let name = hostname();
76
76
  try {
77
77
  const root = join(teamsHome(env), 'workspaces');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
62
62
  "@volter/supercode-frontend": "^0.2.2",
63
63
  "@volter/supercode-harness-sdk": "^0.3.43",
64
+ "@volter/supercode-health": "^0.1.0",
64
65
  "@volter/supercode-terminal": "^0.2.54",
65
66
  "@volter/identity": "^0.4.1",
66
67
  "@volter/teams": "0.2.1",