@volter/supercode-teams 0.3.9 → 0.3.11
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 +43 -4
- package/client.mjs +11 -1
- package/machine.mjs +28 -8
- package/package.json +2 -2
package/bin/teams.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from '
|
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
34
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
35
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
36
|
+
import { noteStep, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
|
|
36
37
|
|
|
37
38
|
// A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
|
|
38
39
|
if (process.env.SUPERCODE_TEAMS_LOG) {
|
|
@@ -49,9 +50,9 @@ import { moveWorkspace } from '../workspace-move.mjs';
|
|
|
49
50
|
import { teamsHome, paths, readJson, AccountLog, parseHostPort } from '@volter/teams/home';
|
|
50
51
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
51
52
|
|
|
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']);
|
|
53
|
+
const MACHINE_VERBS = new Set(['machine', 'slow', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
|
|
53
54
|
// Every flag a machine verb reads; any other is refused rather than silently ignored.
|
|
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']);
|
|
55
|
+
const KNOWN_FLAGS = new Set(['home', 'since', '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']);
|
|
55
56
|
|
|
56
57
|
const argv = process.argv.slice(2);
|
|
57
58
|
const flags = {};
|
|
@@ -68,8 +69,12 @@ for (let i = 0; i < argv.length; i += 1) {
|
|
|
68
69
|
} else positional.push(a);
|
|
69
70
|
}
|
|
70
71
|
const [verb, sub, ...rest] = positional;
|
|
72
|
+
// Each command is timed (the slow log, @volter/supercode-harness-sdk/slow-log); a service, a watch or a forward runs
|
|
73
|
+
// until stopped and is timed one door at a time instead.
|
|
74
|
+
const LONG_RUNNING = new Set(['connect', 'watch', 'port', 'stream', 'attach', 'serve']);
|
|
75
|
+
if (!LONG_RUNNING.has(verb) && !(verb === 'machine' && sub === 'start') && !(verb === 'message' && sub === 'watch')) timeCommand(`teams ${[verb, sub].filter(Boolean).join(' ')}`, argv);
|
|
71
76
|
|
|
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)';
|
|
77
|
+
const MACHINE_HELP = 'machine verbs: machine start|status|restart, slow [--since 1h], 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)';
|
|
73
78
|
|
|
74
79
|
if (verb === undefined || verb === 'help') {
|
|
75
80
|
process.stdout.write(`supercode teams: ${MACHINE_HELP}\n`);
|
|
@@ -109,7 +114,9 @@ function keyBytes(names) {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
async function machineChannel() {
|
|
112
|
-
|
|
117
|
+
const started = performance.now();
|
|
118
|
+
try { return await openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home }); }
|
|
119
|
+
finally { noteStep(flags.machine ? `open machine ${flags.machine}` : 'open the local machine', performance.now() - started); }
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
// The session (or board) this command acts for: the pane doors refuse a session acting on a pane it did not start.
|
|
@@ -176,6 +183,38 @@ async function main() {
|
|
|
176
183
|
if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
|
|
177
184
|
die('machine start|status|restart');
|
|
178
185
|
}
|
|
186
|
+
case 'slow': {
|
|
187
|
+
// `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
|
|
188
|
+
// slowest first, each with its count, median and longest time and its usual slowest step. The audit reads this.
|
|
189
|
+
const since = (() => {
|
|
190
|
+
const value = String(flags.since ?? '24h');
|
|
191
|
+
const relative = /^(\d+)([smhd])$/.exec(value);
|
|
192
|
+
if (relative) return Date.now() - Number(relative[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[relative[2]];
|
|
193
|
+
const at = Date.parse(value);
|
|
194
|
+
if (Number.isNaN(at)) die('slow --since takes 30m, 2h, 1d or a date');
|
|
195
|
+
return at;
|
|
196
|
+
})();
|
|
197
|
+
let text = '';
|
|
198
|
+
for (const file of [`${slowLogPath()}.1`, slowLogPath()]) {
|
|
199
|
+
try { text += readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
200
|
+
}
|
|
201
|
+
const groups = new Map();
|
|
202
|
+
for (const line of text.split('\n')) {
|
|
203
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
204
|
+
if (!(Date.parse(row.at) >= since)) continue;
|
|
205
|
+
const key = `${row.kind} ${row.name}`;
|
|
206
|
+
const group = groups.get(key) ?? { kind: row.kind, name: row.name, count: 0, ms: [], steps: new Map(), last: null };
|
|
207
|
+
group.count += 1; group.ms.push(row.ms); group.last = row.at;
|
|
208
|
+
if (row.slowest?.step) group.steps.set(row.slowest.step, (group.steps.get(row.slowest.step) ?? 0) + 1);
|
|
209
|
+
groups.set(key, group);
|
|
210
|
+
}
|
|
211
|
+
out([...groups.values()].map((group) => {
|
|
212
|
+
const sorted = group.ms.sort((a, b) => a - b);
|
|
213
|
+
const step = [...group.steps].sort((a, b) => b[1] - a[1])[0];
|
|
214
|
+
return { kind: group.kind, name: group.name, count: group.count, median_ms: sorted[Math.floor(sorted.length / 2)], max_ms: sorted.at(-1), usual_slowest_step: step ? step[0] : null, last: group.last };
|
|
215
|
+
}).sort((a, b) => b.max_ms * b.count - a.max_ms * a.count));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
179
218
|
case 'describe':
|
|
180
219
|
return withNode(async (rpc) => out(await rpc.call('describe')));
|
|
181
220
|
case 'panes': {
|
package/client.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
|
|
|
8
8
|
import { Channel } from '@volter/teams/channel';
|
|
9
9
|
import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport } from '@volter/teams/client';
|
|
10
10
|
import { lineSplitter } from '@volter/teams/lines';
|
|
11
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
11
12
|
|
|
12
13
|
export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport };
|
|
13
14
|
|
|
@@ -50,8 +51,17 @@ export async function execServerBridge(channel, harness, { host = '127.0.0.1' }
|
|
|
50
51
|
|
|
51
52
|
/** The node's own doors (describe, grants, launch, files, ports, attention, titles) over the `node` stream. */
|
|
52
53
|
export async function nodeRpc(channel) {
|
|
54
|
+
const opened = performance.now();
|
|
53
55
|
const stream = await channel.open('node');
|
|
54
|
-
|
|
56
|
+
noteStep('open the node stream', performance.now() - opened);
|
|
57
|
+
const rpc = new StreamRpc(stream);
|
|
58
|
+
// Each door call is a step of the running command (the slow log names the slowest).
|
|
59
|
+
const call = rpc.call.bind(rpc);
|
|
60
|
+
rpc.call = async (method, ...rest) => {
|
|
61
|
+
const started = performance.now();
|
|
62
|
+
try { return await call(method, ...rest); } finally { noteStep(`machine door ${method}`, performance.now() - started); }
|
|
63
|
+
};
|
|
64
|
+
return rpc;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
/**
|
package/machine.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
|
|
|
44
44
|
// than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
|
|
45
45
|
import * as terminal from '@volter/supercode-terminal';
|
|
46
46
|
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
47
|
+
import { recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
47
48
|
import { lineSplitter } from '@volter/teams/lines';
|
|
48
49
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
49
50
|
import { holds, observesPanes } from '@volter/teams/admission';
|
|
@@ -235,7 +236,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
235
236
|
}, 1000);
|
|
236
237
|
this.terminalMailTimer.unref?.();
|
|
237
238
|
// What actually runs under each hosted pane, for the trace pack (process-tree.mjs): one read every five seconds.
|
|
238
|
-
|
|
239
|
+
// It needs each pane's root pid only: the pane listing, not attention's screen capture of every pane.
|
|
240
|
+
this.processTree = new ProcessTree({ root: join(this.home, 'processes'), panes: async () => (await this.host.listSessions()).map((session) => ({ pane: session.contextKey, rootPid: session.rootPid })) });
|
|
239
241
|
this.processTimer = setInterval(() => {
|
|
240
242
|
if (this.processWork) return;
|
|
241
243
|
this.processWork = this.processTree.sample().catch(error => this.emit('mail-watch-stderr', `process tree: ${error.message}`)).finally(() => { this.processWork = null; });
|
|
@@ -519,10 +521,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
519
521
|
async #checkEnds() {
|
|
520
522
|
const pending = this.#pendingNotices();
|
|
521
523
|
if (!pending.length) { clearInterval(this.endWatch); this.endWatch = null; return; }
|
|
524
|
+
// One pane listing for every pending notice, not one per notice.
|
|
525
|
+
const panes = await this.host.listSessions();
|
|
522
526
|
for (const ask of pending) {
|
|
523
527
|
// A pane has ended when it is gone, or when its program has exited and the host keeps it for its last screen
|
|
524
528
|
// (a ConPTY pane whose program failed).
|
|
525
|
-
const session =
|
|
529
|
+
const session = panes.find((s) => s.contextKey === ask.pane) ?? null;
|
|
526
530
|
if (session && !session.dead) continue;
|
|
527
531
|
appendFileSync(this.#noticesPath(), JSON.stringify({ t: Date.now(), pane: ask.pane, told: true }) + '\n', { mode: 0o600 });
|
|
528
532
|
const exit = session?.dead ? deadPaneExit(session) : paneExit(ask.pane);
|
|
@@ -959,7 +963,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
959
963
|
const evidence = {
|
|
960
964
|
liveness: own?.liveness ?? livenessOf(session.activeCommand),
|
|
961
965
|
signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
|
|
962
|
-
|
|
966
|
+
// tmux keeps when each pane last printed, so idle time advances between queries; a host that does not say
|
|
967
|
+
// (the Windows pane server) falls back to the screen's own plateau since this daemon last looked.
|
|
968
|
+
idleSeconds: Number.isFinite(session.lastOutputAt) ? Math.max(0, Math.round((now - session.lastOutputAt) / 1000)) : this.#idleSeconds(session.id, capture, now),
|
|
963
969
|
requested,
|
|
964
970
|
...(own ? { livenessSource: 'cooperative' } : {}),
|
|
965
971
|
};
|
|
@@ -1044,9 +1050,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1044
1050
|
}
|
|
1045
1051
|
|
|
1046
1052
|
async #drainTerminalMail() {
|
|
1047
|
-
|
|
1053
|
+
// One pane listing per tick, shared by every mailbox: a listing per mailbox each second was most of this
|
|
1054
|
+
// daemon's tmux spawns (8 retained mailboxes, about 70 tmux processes a second, t_23e47b73).
|
|
1055
|
+
const retained = retainedTerminalMail(this.terminalMailRoot);
|
|
1056
|
+
if (!retained.length && !this.terminalMailboxes.size) return;
|
|
1057
|
+
const panes = await this.host.listSessions();
|
|
1058
|
+
const paneByContext = (contextKey) => panes.find((s) => s.contextKey === contextKey) ?? null;
|
|
1059
|
+
for (const saved of retained) {
|
|
1048
1060
|
if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
|
|
1049
|
-
const session =
|
|
1061
|
+
const session = paneByContext(saved.binding.context);
|
|
1050
1062
|
if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
|
|
1051
1063
|
const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
|
|
1052
1064
|
target: `pane:${saved.binding.context}`, outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
|
|
@@ -1057,7 +1069,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1057
1069
|
// exists. Its mail stays retained: the session's next pane takes it up.
|
|
1058
1070
|
for (const [recipient, queue] of this.terminalMailboxes) {
|
|
1059
1071
|
const binding = queue.state.binding;
|
|
1060
|
-
const live = binding ?
|
|
1072
|
+
const live = binding ? paneByContext(binding.context) : null;
|
|
1061
1073
|
if (!live || live.id !== binding.terminal) this.terminalMailboxes.delete(recipient);
|
|
1062
1074
|
}
|
|
1063
1075
|
await Promise.all([...this.terminalMailboxes.values()].filter(queue => !this.inputRepairs[queue.state.binding?.context] && !this.inputInFlight.has(queue.session?.id)).map(queue => queue.kick()));
|
|
@@ -1497,8 +1509,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
1497
1509
|
const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
|
|
1498
1510
|
reply({ id: request.id, result });
|
|
1499
1511
|
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
|
|
1512
|
+
recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, slowest: null, extra: { principal: entry.principal ?? null } });
|
|
1500
1513
|
} catch (error) {
|
|
1501
1514
|
this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
|
|
1515
|
+
recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, outcome: String(error.code ?? 'error'), slowest: null, extra: { principal: entry.principal ?? null } });
|
|
1502
1516
|
reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
|
|
1503
1517
|
}
|
|
1504
1518
|
}
|
|
@@ -1864,9 +1878,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1864
1878
|
// Everything this daemon launched, by key, with whether the pane still lives: how an
|
|
1865
1879
|
// operator tells its own launches from a person's panes without reading labels.
|
|
1866
1880
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
1881
|
+
// One pane listing for the whole journal: one per launch (thousands of them) ran past the door's 30 s bound,
|
|
1882
|
+
// the slowest door the slow log named on yuerans-macbook-pro (t_23e47b73).
|
|
1883
|
+
// The journal first, then the panes: a launch that becomes ready while the panes are listed is in a listing
|
|
1884
|
+
// taken after it, so it never reads as not live.
|
|
1867
1885
|
const rows = [];
|
|
1868
|
-
|
|
1869
|
-
|
|
1886
|
+
const launched = this.launches.all();
|
|
1887
|
+
const panes = await this.host.listSessions();
|
|
1888
|
+
for (const found of launched) {
|
|
1889
|
+
const session = found.pane ? panes.find((s) => s.contextKey === found.pane) ?? null : null;
|
|
1870
1890
|
rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, by: found.by ?? null, status: found.status ?? null, at: found.t, live: Boolean(session && !session.dead), ...initialInputFacts(found) });
|
|
1871
1891
|
}
|
|
1872
1892
|
return rows;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@volter/supercode-health": "^0.1.0",
|
|
67
67
|
"@volter/supercode-terminal": "^0.2.54",
|
|
68
68
|
"@volter/supercode-trace": "^0.1.0",
|
|
69
|
-
"@volter/teams": "0.2.
|
|
69
|
+
"@volter/teams": "0.2.7",
|
|
70
70
|
"@xterm/addon-serialize": "^0.14.0",
|
|
71
71
|
"@xterm/headless": "^6.0.0",
|
|
72
72
|
"protobufjs": "8.8.0",
|