@volter/supercode-teams 0.3.1 → 0.3.2
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 +44 -3
- package/bin/workspace.mjs +2 -0
- package/machine.mjs +77 -0
- package/package.json +2 -1
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
|
@@ -994,6 +994,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
994
994
|
const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
|
|
995
995
|
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
996
|
machine.machineEvents = machineEvents;
|
|
997
|
+
// The machine-health probe mails as this enrolled machine (`sc:<machine>:operator:machine-health`).
|
|
998
|
+
machine.healthMachine = saved.machine.name;
|
|
997
999
|
connector.closeAgentSession=async id=>{
|
|
998
1000
|
const channel=await openLocalMachine(paths(daemonHome).socket);
|
|
999
1001
|
try{const rpc=await nodeRpc(channel);return await rpc.call('harness.v1.agents.closeSession',{closure:id},{timeoutMs:60000});}
|
package/machine.mjs
CHANGED
|
@@ -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;
|
|
@@ -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;
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|