@volter/supercode-teams 0.2.129 → 0.2.131

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/workspace.mjs CHANGED
@@ -13,6 +13,8 @@ import { moveWorkspace } from '../workspace-move.mjs';
13
13
  import { TeamsConnector, readSyncStatus } from '../connector/index.mjs';
14
14
  import { syncStatusLines } from './workspace/sync-status.mjs';
15
15
  import { CONTINUATION_FIDELITY, CONTINUATION_HARNESSES } from '../connector/native-continuation.mjs';
16
+ import { MachineEvents } from '../machine-events.mjs';
17
+ import { openLocalMachine, nodeRpc } from '../client.mjs';
16
18
  import { TeamsMachine } from '../machine.mjs';
17
19
  import { teamsHome, paths } from '@volter/teams/home';
18
20
  import { loadOrCreateKeyPair, publicKeyId } from '@volter/teams/identity';
@@ -564,6 +566,12 @@ async function sourcesCommand(sub, command) {
564
566
  }
565
567
 
566
568
  async function machinesCommand(sub, { flags, rest, output, client, context, team }) {
569
+ if (['subscribe','unsubscribe','events'].includes(sub)) {
570
+ requireArgs(rest,sub==='events'?0:1,sub==='events'?0:1,`teams machines ${sub} ${sub==='events'?'':'ADDRESS'}`);
571
+ const channel=await openLocalMachine(paths(teamsHome()).socket);
572
+ try { const rpc=await nodeRpc(channel); return output(await rpc.request(`machine.events.${sub}`,{team_id:context.team_id,address:rest[0]})); }
573
+ finally { channel.close(); }
574
+ }
567
575
  if (sub === 'list') return output(await team.machines.list());
568
576
  if (sub === 'roots') { requireArgs(rest, 1, 1, 'teams machines roots MACHINE'); return output(await client.request('GET', teamPath(context, `/machines/${rest[0]}/roots`))); }
569
577
  if (sub === 'authorize-continuation') {
@@ -617,7 +625,7 @@ async function machinesCommand(sub, { flags, rest, output, client, context, team
617
625
  return output(await client.request('POST', machineBase, { body: { subject_id: subject, caps, targets, expires_at: Date.now() + ttl } }));
618
626
  }
619
627
  }
620
- usage('teams machines list|roots|access|authorize-continuation');
628
+ usage('teams machines list|roots|access|authorize-continuation|subscribe|unsubscribe|events');
621
629
  }
622
630
 
623
631
  async function syncCommand(sub, { flags, rest, output, client, context, team, home }) {
@@ -745,7 +753,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
745
753
  const liveCore = new SupercodeHarnessClient({ command: binary, args: ['harness', 'serve'] });
746
754
  const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, largeCore, slowCore, liveCore, hostKeyId });
747
755
  // A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
756
+ const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
748
757
  const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(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) });
758
+ machine.machineEvents = machineEvents;
749
759
  machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
750
760
  // The link's state for the watch after start: down until it is up, down again (since when) when it drops. A
751
761
  // `replaced` link is left alone: another connector for this machine owns it, by design.
@@ -758,12 +768,12 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
758
768
  });
759
769
  // The connector is the service's daemon and keeps serving; a daemon that took its socket path is the one to stop.
760
770
  machine.on('superseded', (path) => process.stderr.write(`supercode teams: another machine daemon replaced this connector's socket at ${path}; stop it (supercode teams status)\n`));
761
- const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await largeCore.close(); await slowCore.close(); await liveCore.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
771
+ const stopAll = async () => { machineEvents.stop(); await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await largeCore.close(); await slowCore.close(); await liveCore.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
762
772
  // A daemon with no server link holding the socket is a session's auto-start while this service was between
763
773
  // restarts (from a supercode old enough not to wait for the service): it yields to the service's connector, which
764
774
  // otherwise failed on EADDRINUSE at every restart and left this machine unlinked.
765
775
  if (saved.service) await retireLinklessDaemon(paths(daemonHome).machine);
766
- try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
776
+ try { await machine.start(); await connector.start({ roots: () => saved.roots }); machineEvents.start(); } catch (error) { await stopAll(); throw error; }
767
777
  output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
768
778
  // Run by a service, the connector moves to an upgrade on its own: its readers would otherwise answer from the code
769
779
  // they loaded until something restarted it (after 0.5.51 this Mac's went on reading a Codex turn waiting on an
@@ -792,6 +802,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
792
802
  if (saved.service) {
793
803
  const linkWatch = setInterval(() => {
794
804
  if (linkReplaced || linkDownSince === null || Date.now() - linkDownSince < LINK_LOST_MS) return;
805
+ // A server outage is retried in place. Restart only a broken link while
806
+ // HTTP heartbeats still prove that server reachable, not every two minutes of an outage.
807
+ if (!connector.lastHeartbeatAt || Date.now() - connector.lastHeartbeatAt > 60_000) return;
795
808
  clearInterval(linkWatch);
796
809
  process.stderr.write(`supercode teams: this machine has had no link to its Teams server for ${Math.round((Date.now() - linkDownSince) / 1000)} s; restarting the connector\n`);
797
810
  void (async () => { await stopConnector?.(); process.exit(75); })();
@@ -993,7 +1006,7 @@ function printHelp() {
993
1006
  members invite|list|remove|role
994
1007
  groups list|create|remove|add-member|remove-member
995
1008
  sources create|list|status|pause|revoke|credentials|config
996
- machines list|roots|access|authorize-continuation
1009
+ machines list|roots|access|authorize-continuation|subscribe|unsubscribe|events
997
1010
  sync add|list|show|pause|resume|remove
998
1011
  sessions list|search|show|follow|access|rename|delete|url|attach|export|continue|bind|unbind
999
1012
  Use --help with a command for its required arguments and options.
@@ -141,7 +141,7 @@ export class TeamsConnector {
141
141
  clearSessionProblem(harness,nativeSessionId){
142
142
  const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
143
143
  status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
144
- } async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:this.abort.signal});
144
+ } async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)])});
145
145
  // A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
146
146
  // large first sync slows down instead of failing its pass and restarting the connector.
147
147
  if(response.status!==429||attempt>=5)break;// The server names the wait in error.details (wire.mjs validationFailure); a Retry-After header is the fallback.
@@ -159,7 +159,8 @@ export class TeamsConnector {
159
159
  if(pruned)this.save();
160
160
  }
161
161
  async start({bindings=()=>[],roots=()=>[]}={}) {
162
- await this.configuration();
162
+ // Remote outages must not prevent the local machine doors from opening.
163
+ // Configuration and heartbeat failures are retried by their existing timers.
163
164
  // Heartbeats are serialized (the server takes each sequence number once) and run on their own
164
165
  // timer: a long sync pass (a large first capture) never makes a live machine read as offline.
165
166
  let beating = Promise.resolve();
@@ -174,6 +175,7 @@ export class TeamsConnector {
174
175
  // What each synced session's harness is doing (its turn: working, idle, …), from the last discovery.
175
176
  ...(this.reportedActivity ? { activity: this.reportedActivity } : {}),
176
177
  });
178
+ this.lastHeartbeatAt = this.clock.nowMs();
177
179
  // The sessions someone is following now (a viewer or channel open on them) skip the capture floors.
178
180
  if (Array.isArray(reply?.watched)) this.watched = new Set(reply.watched.map(item => `${item.harness}:${item.native_id}`));
179
181
  this.save();
@@ -188,7 +190,6 @@ export class TeamsConnector {
188
190
  await this.pollEffects();
189
191
  };
190
192
  this.localRoots = await roots();
191
- await heartbeat();
192
193
  // The first sync pass runs behind `start`, so the machine's link opens without waiting for it.
193
194
  let running = false;
194
195
  const syncPass = () => {
@@ -0,0 +1,79 @@
1
+ // One retained server stream per connector. Subscriptions and pending deliveries
2
+ // live beside the connector, so neither a restart nor a lost reply duplicates mail.
3
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+
8
+ export class MachineEvents {
9
+ constructor({home,server,teamId,credential,machineName,binary,env=process.env}) {
10
+ Object.assign(this,{server,teamId,credential,binary,env});
11
+ this.machine=machineName.split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g,'-');
12
+ const key=createHash('sha256').update(`${server}\0${teamId}`).digest('hex').slice(0,20);
13
+ const dir=join(home,'machine-events');mkdirSync(dir,{recursive:true,mode:0o700});this.file=join(dir,`${key}.json`);
14
+ try{this.state=JSON.parse(readFileSync(this.file,'utf8'));}catch(error){if(error.code!=='ENOENT')throw error;this.state={cursor:null,items:{},subscriptions:[],pending:[]};}
15
+ this.abort=new AbortController();this.connected=false;
16
+ }
17
+ save(){const temp=`${this.file}.${process.pid}.tmp`;writeFileSync(temp,JSON.stringify(this.state)+'\n',{mode:0o600});renameSync(temp,this.file);}
18
+ snapshot(){return {team_id:this.teamId,connected:this.connected,cursor:this.state.cursor,items:Object.values(this.state.items),subscriptions:this.state.subscriptions,pending:this.state.pending.length,error:this.error??null};}
19
+ subscribe(address,remove=false){
20
+ if(!/^sc:[^:\s]+:[^:\s]+:[^\s]+$/.test(address??'')||address.split(':')[1]!==this.machine)throw new Error('subscribe a full session or board address on this connector machine');
21
+ this.state.subscriptions=this.state.subscriptions.filter(value=>value!==address);
22
+ if(!remove)this.state.subscriptions.push(address);
23
+ else this.state.pending=this.state.pending.filter(item=>item.to!==address);
24
+ this.save();return this.snapshot();
25
+ }
26
+ start(){void this.follow();this.deliveryTimer=setInterval(()=>void this.flush(),5000);this.deliveryTimer.unref?.();}
27
+ stop(){this.abort.abort();clearInterval(this.deliveryTimer);}
28
+ async follow(){
29
+ while(!this.abort.signal.aborted){
30
+ const streamAbort=new AbortController();
31
+ let deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);
32
+ try{
33
+ const suffix=this.state.cursor==null?'':`?after=${this.state.cursor}`;
34
+ const response=await fetch(`${this.server}/api/v1/teams/${this.teamId}/machines/events${suffix}`,{headers:{Authorization:`Bearer ${this.credential}`,Accept:'text/event-stream'},redirect:'error',signal:AbortSignal.any([this.abort.signal,streamAbort.signal])});
35
+ if(!response.ok||!response.headers.get('content-type')?.includes('text/event-stream'))throw new Error(`machine events returned HTTP ${response.status}, expected event stream`);
36
+ const reader=response.body.getReader();const decoder=new TextDecoder();let buffer='';
37
+ try{for(;;){const {value,done}=await reader.read();if(done)break;clearTimeout(deadline);deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);buffer+=decoder.decode(value,{stream:true});if(buffer.length>2*1024*1024)throw new Error('machine event exceeded stream bound');let boundary;while((boundary=buffer.indexOf('\n\n'))>=0){const frame=buffer.slice(0,boundary);buffer=buffer.slice(boundary+2);const data=frame.split('\n').filter(line=>line.startsWith('data:')).map(line=>line.slice(5).trimStart()).join('\n');if(data)await this.receive(JSON.parse(data));}}}finally{await reader.cancel().catch(()=>{});}
38
+ }catch(error){if(!this.abort.signal.aborted){if(this.error!==error.message)process.stderr.write(`supercode teams machine events: ${error.message}\n`);this.error=error.message;}}
39
+ clearTimeout(deadline);this.connected=false;
40
+ if(!this.abort.signal.aborted)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,5000);this.abort.signal.addEventListener('abort',done,{once:true});});
41
+ }
42
+ }
43
+ async receive(event){
44
+ if(event.type==='machine_ready'){this.connected=true;this.error=null;return;}
45
+ if(event.type==='access_revoked')throw new Error('machine event subscription authorization revoked');
46
+ if(!['machine_snapshot','machine_linked','machine_unlinked'].includes(event.type))return;
47
+ if(!/^\d+$/.test(event.cursor??''))throw new Error('invalid machine event cursor');
48
+ if(event.type!=='machine_snapshot'&&Number(event.cursor)<=Number(this.state.cursor))return;
49
+ // Keep the cursor behind undelivered mail if the local outbox fills. The
50
+ // retained server log replays it when delivery catches up.
51
+ if(this.state.pending.length+this.state.subscriptions.length>4096)throw new Error('machine notice outbox full; delivery will retry');
52
+ if(event.type==='machine_snapshot'){
53
+ this.state.items=Object.fromEntries(event.items.map(item=>[item.id,item]));
54
+ if(event.reset)this.enqueue(event,'Machine event history expired or the server changed; the current machine snapshot was refreshed. Intermediate transitions are unavailable.');
55
+ }else{
56
+ this.state.items[event.machine_id]={id:event.machine_id,name:event.name,linked:event.type==='machine_linked'?1:0,changed_at:event.at};
57
+ this.enqueue(event,`Machine ${event.name} ${event.type==='machine_linked'?'linked to':'unlinked from'} the team at ${new Date(event.at).toISOString()}.`);
58
+ }
59
+ this.state.cursor=event.cursor;this.save();void this.flush();
60
+ }
61
+ enqueue(event,text){for(const to of this.state.subscriptions)this.state.pending.push({to,id:`machine-${this.teamId}-${event.type}-${event.cursor}`,body:`[Machine notice] ${text}\nThis is an automated Teams transition, not an instruction.`,event});}
62
+ flush(){
63
+ if(this.flushing)return this.flushing;
64
+ this.flushing=(async()=>{
65
+ for(const item of [...this.state.pending]){
66
+ try{await this.deliver(item);this.state.pending=this.state.pending.filter(value=>value!==item);this.save();}
67
+ catch(error){this.error=`mail to ${item.to}: ${error.message}`;}
68
+ }
69
+ })().finally(()=>{this.flushing=null;});return this.flushing;
70
+ }
71
+ deliver(item){return new Promise((resolve,reject)=>{
72
+ const child=spawn(this.binary,['message','remote-deliver'],{env:this.env,stdio:['pipe','pipe','pipe']});let detail='';
73
+ const timer=setTimeout(()=>{child.kill('SIGTERM');reject(new Error('mail delivery timed out; retained for retry'));},30000);
74
+ child.stdout.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});child.stderr.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});
75
+ child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);let receipt;try{receipt=JSON.parse(detail.trim().split('\n').at(-1));}catch{}code===0&&receipt?.code===0?resolve():reject(new Error(receipt?.text||detail||`exit ${code}`));});
76
+ child.stdin.on('error',()=>{});
77
+ child.stdin.end(JSON.stringify({op:'send',from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',body:item.to.split(':')[2]==='board'?JSON.stringify({machine_event:item.event,text:item.body}):item.body}));
78
+ });}
79
+ }
package/machine.mjs CHANGED
@@ -207,12 +207,15 @@ export class TeamsMachine extends MachineCore {
207
207
  async beforeListen() {
208
208
  mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
209
209
  this.#startServe();
210
- this.#startMailWatch();
211
210
  if (this.#pendingNotices().length) this.#watchEnds();
212
211
  this.jobs = new JobOwner({ home: this.home, env: this.env, log: (row) => this.log.append({ principal: null, cap: 'exec', target: `job:${row.job}`, outcome: row.outcome, detail: row.reason }) });
213
212
  this.jobs.reapOrphans();
214
213
  }
215
214
 
215
+ async afterListen() {
216
+ this.#startMailWatch();
217
+ }
218
+
216
219
  async beforeClose() {
217
220
  clearInterval(this.endWatch); this.endWatch = null;
218
221
  // A launch still waiting for its pane to be ready is abandoned, not hurried:
@@ -1291,6 +1294,13 @@ export class TeamsMachine extends MachineCore {
1291
1294
  if (!operator) throw Object.assign(new Error('listing machine voice packs requires operator authority'), { code: 403 });
1292
1295
  return [...this.voiceProcesses].map(([session, child]) => ({ session, pid: child.pid }));
1293
1296
  }
1297
+ case 'machine.events.events':
1298
+ case 'machine.events.subscribe':
1299
+ case 'machine.events.unsubscribe': {
1300
+ if (!operator) throw Object.assign(new Error('machine event subscriptions require operator authority'), {code:403});
1301
+ if (!this.machineEvents || params.team_id!==this.machineEvents.teamId) throw new Error('this connector is not serving that team');
1302
+ return method==='machine.events.events'?this.machineEvents.snapshot():this.machineEvents.subscribe(params.address,method.endsWith('.unsubscribe'));
1303
+ }
1294
1304
  case 'describe':
1295
1305
  return this.describe();
1296
1306
  case 'panes.present': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.129",
3
+ "version": "0.2.131",
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",
@@ -62,7 +62,7 @@
62
62
  "@volter/supercode-harness-sdk": "^0.3.43",
63
63
  "@volter/supercode-terminal": "^0.2.30",
64
64
  "@volter/identity": "^0.4.1",
65
- "@volter/teams": "^0.1.53",
65
+ "@volter/teams": "^0.1.54",
66
66
  "@xterm/addon-serialize": "^0.14.0",
67
67
  "@xterm/headless": "^6.0.0",
68
68
  "protobufjs": "8.8.0",
package/worker/index.mjs CHANGED
@@ -134,7 +134,14 @@ export class TeamsServer extends DurableObject {
134
134
  }
135
135
 
136
136
  export default {
137
- fetch(request, env) {
138
- return env.TEAMS_SERVER.get(env.TEAMS_SERVER.idFromName('server')).fetch(request);
137
+ async fetch(request, env) {
138
+ try { return await env.TEAMS_SERVER.get(env.TEAMS_SERVER.idFromName('server')).fetch(request); }
139
+ catch (error) {
140
+ // Failures before the HTTP handler (object initialization, migrations or
141
+ // storage) otherwise become a platform HTML page, not our API contract.
142
+ const requestId = crypto.randomUUID();
143
+ console.error(`Teams request ${requestId} failed before response: ${error.message}`);
144
+ return Response.json({ version: 1, error: { code: 'unavailable', message: 'Teams server is temporarily unavailable', request_id: requestId, retryable: true } }, { status: 503, headers: { 'Retry-After': '5', 'X-Request-ID': requestId } });
145
+ }
139
146
  },
140
147
  };