@volter/supercode-teams 0.3.20 → 0.3.22
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 +2 -2
- package/bin/workspace.mjs +4 -1
- package/event-client.mjs +1 -1
- package/machine-events.mjs +160 -7
- package/machine.mjs +15 -2
- package/package.json +2 -2
- package/server/event-routes.mjs +7 -10
- package/server/harness-routes.mjs +1 -1
- package/server/store-core.mjs +7 -2
package/bin/teams.mjs
CHANGED
|
@@ -33,7 +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
|
+
import { noteStep, rotatedLogs, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
|
|
37
37
|
|
|
38
38
|
// A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
|
|
39
39
|
if (process.env.SUPERCODE_TEAMS_LOG) {
|
|
@@ -197,7 +197,7 @@ async function main() {
|
|
|
197
197
|
return at;
|
|
198
198
|
})();
|
|
199
199
|
let text = '';
|
|
200
|
-
for (const file of [
|
|
200
|
+
for (const file of [...rotatedLogs(), slowLogPath()]) {
|
|
201
201
|
try { text += readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
202
202
|
}
|
|
203
203
|
const groups = new Map();
|
package/bin/workspace.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { TeamsConnector, readSyncStatus } from '../connector/index.mjs';
|
|
|
16
16
|
import { syncStatusLines } from './workspace/sync-status.mjs';
|
|
17
17
|
import { calendarCommand } from './workspace/calendar.mjs';
|
|
18
18
|
import { CONTINUATION_FIDELITY, CONTINUATION_HARNESSES } from '../connector/native-continuation.mjs';
|
|
19
|
-
import { MachineEvents } from '../machine-events.mjs';
|
|
19
|
+
import { MachineEvents, PublishedEvents } from '../machine-events.mjs';
|
|
20
20
|
import { openLocalMachine, nodeRpc } from '../client.mjs';
|
|
21
21
|
import { TeamsMachine } from '../machine.mjs';
|
|
22
22
|
import { teamsHome, paths } from '@volter/teams/home';
|
|
@@ -1039,6 +1039,9 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
1039
1039
|
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 });
|
|
1040
1040
|
// A connected machine is a person's: each pane it launches opens a window on its screen.
|
|
1041
1041
|
const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
|
|
1042
|
+
// The team's published events, one stream for this install with the connected context's credential, served to this
|
|
1043
|
+
// machine's processes over the daemon's `events` door (@volter/teams/event-client reads there first).
|
|
1044
|
+
if (!context.machine_only) machineEvents.published = new PublishedEvents({ supercodeHome: home, context: contextName, machineId: saved.machine.id });
|
|
1042
1045
|
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) });
|
|
1043
1046
|
machine.machineEvents = machineEvents;
|
|
1044
1047
|
// The machine-health probe mails as this enrolled machine (`sc:<machine>:operator:machine-health`).
|
package/event-client.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { eventClient } from "@volter/teams/event-client";
|
|
1
|
+
export { eventClient, retryAfterMs } from "@volter/teams/event-client";
|
package/machine-events.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// One retained server stream per connector. Subscriptions and pending deliveries
|
|
2
2
|
// live beside the connector, so neither a restart nor a lost reply duplicates mail.
|
|
3
|
+
// The team's published events are the connector's too: one stream per team install, read by this machine's own
|
|
4
|
+
// processes through the daemon's `events` door (PublishedEvents), never one stream per process.
|
|
3
5
|
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
4
6
|
import { join } from 'node:path';
|
|
5
7
|
import { spawn } from 'node:child_process';
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import { fromContext } from '@volter/teams/context';
|
|
10
|
+
import { retryAfterMs } from '@volter/teams/http-client';
|
|
7
11
|
|
|
8
12
|
export class MachineEvents {
|
|
9
13
|
constructor({home,server,teamId,credential,machineName,binary,env=process.env}) {
|
|
@@ -15,7 +19,7 @@ export class MachineEvents {
|
|
|
15
19
|
this.abort=new AbortController();this.connected=false;
|
|
16
20
|
}
|
|
17
21
|
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};}
|
|
22
|
+
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,published:this.published?.status()??null};}
|
|
19
23
|
subscribe(address,remove=false){
|
|
20
24
|
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
25
|
this.state.subscriptions=this.state.subscriptions.filter(value=>value!==address);
|
|
@@ -24,20 +28,22 @@ export class MachineEvents {
|
|
|
24
28
|
this.save();return this.snapshot();
|
|
25
29
|
}
|
|
26
30
|
start(){void this.follow();this.deliveryTimer=setInterval(()=>void this.flush(),5000);this.deliveryTimer.unref?.();}
|
|
27
|
-
stop(){this.abort.abort();clearInterval(this.deliveryTimer);}
|
|
31
|
+
stop(){this.abort.abort();clearInterval(this.deliveryTimer);this.published?.stop();}
|
|
28
32
|
async follow(){
|
|
33
|
+
let refused=0;
|
|
29
34
|
while(!this.abort.signal.aborted){
|
|
30
|
-
const streamAbort=new AbortController();
|
|
35
|
+
const streamAbort=new AbortController();let pause=5000;
|
|
31
36
|
let deadline=setTimeout(()=>streamAbort.abort(new Error('machine event stream timed out')),45000);
|
|
32
37
|
try{
|
|
33
38
|
const suffix=this.state.cursor==null?'':`?after=${this.state.cursor}`;
|
|
34
39
|
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`);
|
|
40
|
+
if(!response.ok||!response.headers.get('content-type')?.includes('text/event-stream')){const body=response.ok?null:await response.json().catch(()=>null);throw Object.assign(new Error(`machine events returned HTTP ${response.status}, expected event stream`),{retryAfterMs:retryAfterMs(body?.error,response)});}
|
|
41
|
+
refused=0;
|
|
36
42
|
const reader=response.body.getReader();const decoder=new TextDecoder();let buffer='';
|
|
37
43
|
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){this.failingSince??=Date.now();if(this.error!==error.message)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: ${error.message} (cursor ${this.state.cursor})\n`);this.error=error.message;}}
|
|
44
|
+
}catch(error){if(error.retryAfterMs){pause=Math.min(300000,error.retryAfterMs*2**refused);refused++;}if(!this.abort.signal.aborted){this.failingSince??=Date.now();if(this.error!==error.message)process.stderr.write(`${new Date().toISOString()} supercode teams machine events: ${error.message} (cursor ${this.state.cursor})\n`);this.error=error.message;}}
|
|
39
45
|
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,
|
|
46
|
+
if(!this.abort.signal.aborted)await new Promise(resolve=>{const done=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,pause);this.abort.signal.addEventListener('abort',done,{once:true});});
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
async receive(event){
|
|
@@ -81,3 +87,150 @@ export class MachineEvents {
|
|
|
81
87
|
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',notice:true,body:item.body}));
|
|
82
88
|
});}
|
|
83
89
|
}
|
|
90
|
+
|
|
91
|
+
// What a local reader's cursor names: this connector's stream continuity (its epoch) and a server event sequence. A
|
|
92
|
+
// cursor of another epoch, or one the ring no longer covers, is resync_required, as the server answers a stale one.
|
|
93
|
+
const LOCAL_CURSOR=/^local\.([0-9a-f]{16})\.(\d+)$/;
|
|
94
|
+
// The ring that resumes a reader from its cursor: the last 4096 events, at most 16 MB of them.
|
|
95
|
+
const RING_EVENTS=4096,RING_BYTES=16*1024*1024;
|
|
96
|
+
// A local reader with more than this waiting unread is slow, as the server counts one (sseRoom): it waits 30 s for
|
|
97
|
+
// the backlog to drain, then resyncs.
|
|
98
|
+
const READER_BACKLOG=8*1024*1024,READER_PATIENCE_MS=30000;
|
|
99
|
+
const keyOf=event=>`${event.resource.kind}\0${event.resource.id}`;
|
|
100
|
+
const sleep=(ms,signal)=>new Promise(resolve=>{const done=()=>{clearTimeout(timer);signal.removeEventListener('abort',done);resolve();},timer=setTimeout(done,ms);signal.addEventListener('abort',done,{once:true});});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The team's published events (`team.events.follow`), followed once by this connector with the credential of the context
|
|
104
|
+
* it was connected with, and served to this machine's own readers over the daemon's `events` door with the server's
|
|
105
|
+
* frames and semantics: snapshot_begin, snapshot_item, snapshot_end, event, checkpoint, a terminal resync_required or
|
|
106
|
+
* access_revoked; a `kinds` filter as the server applies it; resume from a cursor. The stream opens with its first
|
|
107
|
+
* reader and is unfiltered, so every reader's view is exact; a reader of another credential, or of one source, is not
|
|
108
|
+
* this door's (it is refused, and reads the server itself).
|
|
109
|
+
*/
|
|
110
|
+
export class PublishedEvents {
|
|
111
|
+
constructor({supercodeHome,context,machineId=null,log=message=>process.stderr.write(`${new Date().toISOString()} supercode teams published events: ${message}\n`)}){
|
|
112
|
+
Object.assign(this,{supercodeHome,contextName:context,machineId,log});
|
|
113
|
+
this.abort=new AbortController();this.started=false;this.readers=new Set();this.catalogue=null;this.error=null;
|
|
114
|
+
// The stream's state: the server cursor it resumes from, each resource's latest event, and the recent events.
|
|
115
|
+
this.cursor=null;this.epoch=null;this.live=false;this.pending=null;this.resources=new Map();this.ring=[];this.ringBytes=0;this.head=0;this.floor=0;
|
|
116
|
+
}
|
|
117
|
+
selected(){return fromContext({supercodeHome:this.supercodeHome,name:this.contextName,fetch:globalThis.fetch});}
|
|
118
|
+
/** Whether a follow is this connector's to serve exactly: its server, team and credential, every source. */
|
|
119
|
+
serves(params){
|
|
120
|
+
let context;try{context=this.selected().context;}catch{return false;}
|
|
121
|
+
return !context.machine_only&¶ms.server_id===context.server_id&¶ms.team_id===context.team_id&¶ms.credential_ref===context.credential_ref&¶ms.source==null
|
|
122
|
+
&&(params.kinds==null||Array.isArray(params.kinds)&¶ms.kinds.length>0&¶ms.kinds.every(kind=>typeof kind==='string'&&/^[a-z_]+\.[a-z_]+$/.test(kind)))
|
|
123
|
+
&&(params.after==null||typeof params.after==='string');
|
|
124
|
+
}
|
|
125
|
+
status(){return {context:this.contextName,connected:this.live,epoch:this.epoch,head:this.head,resources:this.resources.size,ring:this.ring.length,readers:this.readers.size,error:this.error};}
|
|
126
|
+
serve(stream,accept,refuse){
|
|
127
|
+
const params=stream.params??{};
|
|
128
|
+
if(!this.serves(params))return refuse('this connector does not follow published events for that credential');
|
|
129
|
+
accept();
|
|
130
|
+
const kinds=params.kinds==null?null:new Set(params.kinds);
|
|
131
|
+
const reader={stream,kinds,resources:kinds&&new Set([...kinds].map(kind=>kind.split('.')[0])),after:params.after??null,at:0,ready:false,admitting:false};
|
|
132
|
+
this.readers.add(reader);stream.on('close',()=>this.readers.delete(reader));
|
|
133
|
+
this.start();
|
|
134
|
+
if(this.live)void this.admit(reader);
|
|
135
|
+
}
|
|
136
|
+
start(){if(this.started)return;this.started=true;void this.follow();}
|
|
137
|
+
stop(){this.abort.abort();for(const reader of this.readers)reader.stream.close('connector stopped');this.readers.clear();}
|
|
138
|
+
async follow(){
|
|
139
|
+
let refused=0;
|
|
140
|
+
while(!this.abort.signal.aborted){
|
|
141
|
+
let pause=5000;
|
|
142
|
+
try{
|
|
143
|
+
const {client,context}=this.selected();client.machine=this.machineId;
|
|
144
|
+
const team=client.team(context.team_id);
|
|
145
|
+
this.catalogue??=new Set((await team.events.catalogue({signal:this.abort.signal})).data.kinds);
|
|
146
|
+
for await(const frame of team.events.follow({},{signal:this.abort.signal,after:this.cursor??undefined})){refused=0;this.receive(frame);if(frame.terminal)break;}
|
|
147
|
+
this.error='event stream ended';
|
|
148
|
+
}catch(error){
|
|
149
|
+
// Refused for load, it waits out the server's retry_after, doubling while refused (to 5 min), cursor kept.
|
|
150
|
+
const asked=retryAfterMs(error);if(asked){pause=Math.min(300000,asked*2**refused);refused++;}
|
|
151
|
+
if(error.status===409){this.cursor=null;this.reset('resync_required','upstream_resync');}
|
|
152
|
+
if(!this.abort.signal.aborted){const message=`${error.code??'error'}: ${error.message}`;if(message!==this.error)this.log(`${message}${asked?` (retrying in ${Math.round(pause/1000)} s)`:''}`);this.error=message;}
|
|
153
|
+
}
|
|
154
|
+
this.down(this.error??'event stream ended');
|
|
155
|
+
if(!this.abort.signal.aborted)await sleep(pause,this.abort.signal);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
receive(frame){
|
|
159
|
+
if(frame.type==='snapshot_begin'){if(this.epoch)this.reset('resync_required','upstream_resync');this.pending=new Map();return;}
|
|
160
|
+
if(frame.type==='snapshot_item'){(this.pending??this.resources).set(keyOf(frame.data),{key:keyOf(frame.data),sequence:frame.sequence,data:frame.data});return;}
|
|
161
|
+
if(frame.type==='snapshot_end'){
|
|
162
|
+
this.resources=this.pending??new Map();this.pending=null;this.cursor=frame.cursor;
|
|
163
|
+
this.head=0;for(const item of this.resources.values())this.head=Math.max(this.head,item.sequence);
|
|
164
|
+
this.floor=this.head;this.ring=[];this.ringBytes=0;this.epoch=randomBytes(8).toString('hex');this.up();return;
|
|
165
|
+
}
|
|
166
|
+
if(frame.type==='event'){
|
|
167
|
+
this.cursor=frame.cursor;if(!this.epoch||frame.sequence<=this.head)return;
|
|
168
|
+
const key=keyOf(frame.data),old=this.resources.get(key);
|
|
169
|
+
if(!old||old.sequence<frame.sequence)this.resources.set(key,{key,sequence:frame.sequence,data:frame.data});
|
|
170
|
+
const bytes=JSON.stringify(frame.data).length;this.ring.push({sequence:frame.sequence,data:frame.data,bytes});this.ringBytes+=bytes;this.head=frame.sequence;
|
|
171
|
+
while(this.ring.length>RING_EVENTS||this.ringBytes>RING_BYTES){const dropped=this.ring.shift();this.ringBytes-=dropped.bytes;this.floor=dropped.sequence;}
|
|
172
|
+
if(!this.live){this.up();return;}
|
|
173
|
+
for(const reader of this.readers)if(reader.ready){reader.at=frame.sequence;if(this.wants(reader,frame.data))this.put(reader,{type:'event',sequence:frame.sequence,cursor:this.local(frame.sequence),data:frame.data});}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if(frame.type==='checkpoint'){
|
|
177
|
+
this.cursor=frame.cursor;if(!this.epoch)return;
|
|
178
|
+
if(!this.live){this.up();return;}
|
|
179
|
+
for(const reader of this.readers)if(reader.ready)this.put(reader,{type:'checkpoint',cursor:this.local(this.head)});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if(frame.terminal){this.cursor=null;this.reset(frame.type==='access_revoked'?'access_revoked':'resync_required',frame.reason??'upstream');}
|
|
183
|
+
}
|
|
184
|
+
local(sequence){return `local.${this.epoch}.${sequence}`;}
|
|
185
|
+
wants(reader,event){return !reader.kinds||reader.kinds.has(event.kind);}
|
|
186
|
+
// As the server's snapshot: a resource whose kind a subscribed kind names, or whose latest event is a subscribed kind.
|
|
187
|
+
inSnapshot(reader,event){return !reader.kinds||reader.resources.has(event.resource.kind)||reader.kinds.has(event.kind);}
|
|
188
|
+
up(){this.live=true;this.error=null;for(const reader of this.readers)if(!reader.ready&&!reader.admitting)void this.admit(reader);}
|
|
189
|
+
// The stream dropped: readers it was serving are let go (not told to resync) and resume from their cursors once it
|
|
190
|
+
// is back; a reader still waiting for it waits on.
|
|
191
|
+
down(message){this.live=false;for(const reader of [...this.readers])if(reader.ready||reader.admitting){this.readers.delete(reader);reader.stream.close(`unavailable: ${message}`);}}
|
|
192
|
+
// The stream's continuity ended (a new snapshot, a revoked credential): every reader's cursor is void.
|
|
193
|
+
reset(type,reason){this.epoch=null;this.live=false;for(const reader of [...this.readers])this.terminal(reader,type,reason);}
|
|
194
|
+
terminal(reader,type,reason){this.readers.delete(reader);if(reader.stream.closed)return;try{reader.stream.write(JSON.stringify({type,reason,terminal:true}));}catch{}reader.stream.close(type);}
|
|
195
|
+
put(reader,frame){
|
|
196
|
+
if(reader.stream.closed)return false;
|
|
197
|
+
if((reader.stream.channel?.socket?.writableLength??0)>READER_BACKLOG){this.terminal(reader,'resync_required','slow_reader');return false;}
|
|
198
|
+
try{reader.stream.write(JSON.stringify(frame));return true;}catch{return false;}
|
|
199
|
+
}
|
|
200
|
+
async write(reader,frame){
|
|
201
|
+
const socket=reader.stream.channel?.socket;
|
|
202
|
+
if(socket&&socket.writableLength>READER_BACKLOG/2){
|
|
203
|
+
const drained=await new Promise(resolve=>{const done=ok=>{clearTimeout(timer);socket.off('drain',ready);socket.off('close',gone);resolve(ok);},ready=()=>done(true),gone=()=>done(false),timer=setTimeout(()=>done(false),READER_PATIENCE_MS);socket.on('drain',ready);socket.on('close',gone);});
|
|
204
|
+
if(!drained){this.terminal(reader,'resync_required','slow_reader');return false;}
|
|
205
|
+
}
|
|
206
|
+
return this.put(reader,frame);
|
|
207
|
+
}
|
|
208
|
+
async admit(reader){
|
|
209
|
+
const epoch=this.epoch;reader.admitting=true;
|
|
210
|
+
try{
|
|
211
|
+
if(reader.kinds&&this.catalogue&&[...reader.kinds].some(kind=>!this.catalogue.has(kind))){this.readers.delete(reader);reader.stream.close('invalid_request: invalid event subscription');return;}
|
|
212
|
+
if(reader.after==null){
|
|
213
|
+
const items=[...this.resources.values()].filter(item=>this.inSnapshot(reader,item.data)).sort((a,b)=>a.key<b.key?-1:a.key>b.key?1:0);
|
|
214
|
+
reader.at=this.head;
|
|
215
|
+
if(!await this.write(reader,{type:'snapshot_begin'}))return;
|
|
216
|
+
for(const item of items){if(this.epoch!==epoch)return this.terminal(reader,'resync_required','upstream_resync');if(!await this.write(reader,{type:'snapshot_item',sequence:item.sequence,data:item.data}))return;}
|
|
217
|
+
if(this.epoch!==epoch)return this.terminal(reader,'resync_required','upstream_resync');
|
|
218
|
+
if(!await this.write(reader,{type:'snapshot_end',cursor:this.local(reader.at)}))return;
|
|
219
|
+
}else{
|
|
220
|
+
const match=LOCAL_CURSOR.exec(reader.after),at=match?Number(match[2]):NaN;
|
|
221
|
+
if(!match||match[1]!==epoch||at<this.floor||at>this.head)return this.terminal(reader,'resync_required','cursor');
|
|
222
|
+
reader.at=at;
|
|
223
|
+
}
|
|
224
|
+
// Catch up from the ring, then go live: the last step does not wait, so no event falls between the two.
|
|
225
|
+
for(;;){
|
|
226
|
+
if(this.epoch!==epoch||reader.at<this.floor)return this.terminal(reader,'resync_required','retention');
|
|
227
|
+
let low=0,high=this.ring.length;while(low<high){const mid=(low+high)>>1;if(this.ring[mid].sequence<=reader.at)low=mid+1;else high=mid;}
|
|
228
|
+
const next=this.ring[low];if(!next)break;
|
|
229
|
+
reader.at=next.sequence;
|
|
230
|
+
if(this.wants(reader,next.data)&&!await this.write(reader,{type:'event',sequence:next.sequence,cursor:this.local(next.sequence),data:next.data}))return;
|
|
231
|
+
}
|
|
232
|
+
if(!this.live||reader.stream.closed)return;
|
|
233
|
+
reader.ready=true;this.put(reader,{type:'checkpoint',cursor:this.local(this.head)});
|
|
234
|
+
}finally{reader.admitting=false;}
|
|
235
|
+
}
|
|
236
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// stream "product" a target another product serves here (products.mjs), admitted first
|
|
16
16
|
// stream "exec" | "sftp" | "exec-server" operators: a pilot's tool process or file view
|
|
17
17
|
// stream "workspace" operators: a project leaving or arriving
|
|
18
|
+
// stream "events" local callers only: the team's published events from the one stream the connector holds
|
|
18
19
|
//
|
|
19
20
|
// Two doors reach it. The LOCAL door is an owner-only Unix socket in the home:
|
|
20
21
|
// whoever can open it is this OS user, an operator. The REMOTE door is the
|
|
@@ -267,6 +268,14 @@ export class TeamsMachine extends MachineCore {
|
|
|
267
268
|
for (const name of ['exec', 'sftp', 'exec-server']) this.registerDoor(name, (entry, stream, accept, refuse) => this.#serveExecution(entry, stream, accept, refuse));
|
|
268
269
|
this.registerDoor('workspace', (entry, stream, accept, refuse) => this.#serveWorkspace(entry, stream, accept, refuse));
|
|
269
270
|
this.registerDoor('mail', (entry, stream, accept, refuse) => this.#serveMail(entry, stream, accept, refuse));
|
|
271
|
+
// This machine's processes read the team's published events here, from the one stream its connector holds
|
|
272
|
+
// (machine-events.mjs PublishedEvents); a caller it refuses reads the server itself.
|
|
273
|
+
this.registerDoor('events', (entry, stream, accept, refuse) => {
|
|
274
|
+
if (entry.principal !== 'local') return refuse('published events are served to this machine\'s own processes');
|
|
275
|
+
const published = this.machineEvents?.published;
|
|
276
|
+
if (!published) return refuse('this daemon is not a connector following published events');
|
|
277
|
+
return published.serve(stream, accept, refuse);
|
|
278
|
+
});
|
|
270
279
|
}
|
|
271
280
|
|
|
272
281
|
// ---- lifecycle ------------------------------------------------------------
|
|
@@ -808,8 +817,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
808
817
|
// A reused pane key still gets a new receipt baseline. A late hook from
|
|
809
818
|
// its previous process cannot publish evidence for this launch.
|
|
810
819
|
const binding = join(dir, `${pane}-${randomBytes(9).toString('base64url')}.json`);
|
|
811
|
-
|
|
812
|
-
|
|
820
|
+
// Claude records an argv prompt verbatim when it runs at once, but trimmed
|
|
821
|
+
// when it queues behind work restored on resume (a pending task
|
|
822
|
+
// notification). Sending the trimmed text makes both records equal it.
|
|
823
|
+
const prompt = params.kind === 'claude-code' && typeof params.initialInput === 'string' ? params.initialInput.trim() : params.initialInput;
|
|
824
|
+
const initialInput = prompt && !args.includes('--') &&
|
|
825
|
+
!args.some((arg) => arg === '--remote' || arg.startsWith('--remote=')) ? prompt : null;
|
|
813
826
|
writeFileSync(binding, JSON.stringify({ kind: params.kind, pane, by, program: params.program, arguments: args, initialInput }), { mode: 0o600 });
|
|
814
827
|
if (initialInput) nativeInput({ binding, kind: params.kind, text: initialInput, startedAt: Date.now() });
|
|
815
828
|
const claudeCommand = (next) => launchCommand(params.program, initialInput ? [...next, '--', initialInput] : next);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
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.12",
|
|
70
70
|
"@xterm/addon-serialize": "^0.14.0",
|
|
71
71
|
"@xterm/headless": "^6.0.0",
|
|
72
72
|
"protobufjs": "8.8.0",
|
package/server/event-routes.mjs
CHANGED
|
@@ -3,21 +3,18 @@ import { appAuthentication } from '@volter/teams/server/apps';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { validateListQuery } from '../contracts/index.mjs';
|
|
5
5
|
import { cursorHash, cursorToken, liveBindings } from './catalog.mjs';
|
|
6
|
-
import { sseRoom, writeSse } from '@volter/teams/server/events';
|
|
6
|
+
import { sseRoom, writeSse, holdStream } from '@volter/teams/server/events';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
api.openStreams.set(auth.credential_id, count + 1);
|
|
8
|
+
// Counted per team install (holdStream): a refusal names the server's retry-after, never a tight retry.
|
|
9
|
+
async function openStream(api, req, res, auth, requestId) {
|
|
10
|
+
const release = await holdStream(api, auth, req);
|
|
12
11
|
let closed = false;
|
|
13
12
|
const cleanups = [];
|
|
14
13
|
const close = () => {
|
|
15
14
|
if (closed) return;
|
|
16
15
|
closed = true;
|
|
17
16
|
for (const cleanup of cleanups) cleanup();
|
|
18
|
-
|
|
19
|
-
if (next > 0) api.openStreams.set(auth.credential_id, next);
|
|
20
|
-
else api.openStreams.delete(auth.credential_id);
|
|
17
|
+
release();
|
|
21
18
|
res.end();
|
|
22
19
|
};
|
|
23
20
|
req.once('close', close);
|
|
@@ -124,7 +121,7 @@ export async function handleSessionEvents({ api, req, res, auth, id, url, reques
|
|
|
124
121
|
]);
|
|
125
122
|
if (delivered > team.journal_revision || delivered < team.journal_revision && (retained.first_revision === null || delivered < retained.first_revision - 1)) fail(409, 'resync_required', 'event cursor is outside retained journal');
|
|
126
123
|
}
|
|
127
|
-
const stream = openStream(api, req, res, auth, requestId
|
|
124
|
+
const stream = await openStream(api, req, res, auth, requestId);
|
|
128
125
|
if (after === null && !await stream.write({ type: 'snapshot', cursor: initial.event_cursor, revision: initial.revision, data: initial })) return;
|
|
129
126
|
const enqueue = streamQueue(stream, async () => {
|
|
130
127
|
if(!auth.bound&&!await appAuthentication(api,auth))fail(401,'authentication_required','app authorization changed');
|
|
@@ -221,7 +218,7 @@ export async function handleCatalogEvents({ api, req, res, auth, url, requestId,
|
|
|
221
218
|
const query = qobj(url); delete query.after;
|
|
222
219
|
validateListQuery(query);
|
|
223
220
|
if (query.cursor) fail(400, 'invalid_request', 'catalog streams use current windows, not page cursors');
|
|
224
|
-
const stream = openStream(api, req, res, auth, requestId
|
|
221
|
+
const stream = await openStream(api, req, res, auth, requestId);
|
|
225
222
|
let lastCursor = null;
|
|
226
223
|
const enqueue = streamQueue(stream, async () => {
|
|
227
224
|
const page = await catalogWindow(api, auth, query);
|
|
@@ -440,7 +440,7 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
|
|
|
440
440
|
*/
|
|
441
441
|
async function viewBindingRoute(api,req,res,auth,ref,requestId){
|
|
442
442
|
if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential cannot bind sessions');
|
|
443
|
-
const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs()});
|
|
443
|
+
const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs(),forInstaller:true});
|
|
444
444
|
const list=async()=>api.store.query('SELECT issuer,room,conversation,bound_by,created_at FROM session_view_bindings WHERE team_id=? AND session_id=? ORDER BY created_at',[auth.team_id,session.id]);
|
|
445
445
|
if(req.method==='GET'){
|
|
446
446
|
const bindings=await list();
|
package/server/store-core.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
28
28
|
const {teamId,principalId,credentialId,ref,now,bound}=args;
|
|
29
29
|
// `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
|
|
30
30
|
// session its owner bound to that Room and Conversation; it holds no credential and is no member.
|
|
31
|
-
let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false;
|
|
31
|
+
let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false,installerFor=null;
|
|
32
32
|
if((args.delegationDepth??0)>16)domainError(401,'authentication_required','delegation lineage is too deep');
|
|
33
33
|
if(bound){
|
|
34
34
|
if(!get(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE i.team_id=? AND a.issuer=? AND i.id=? AND i.created_at=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='delegation.sessions')`,[teamId,bound.issuer,bound.installationId,bound.installedAt]))domainError(401,'authentication_required','integration was uninstalled');
|
|
@@ -38,11 +38,15 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
38
38
|
membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
|
|
39
39
|
const identity=get(`SELECT i.state,a.state app_state,m.active,m.role FROM identity_app_credentials c JOIN app_installations i ON i.id=c.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.installer_id WHERE c.credential_id=?`,[credentialId]);
|
|
40
40
|
if(identity&&(identity.state!=='active'||identity.app_state!=='active'||!identity.active||!['owner','admin'].includes(identity.role)))domainError(401,'authentication_required','identity integration revoked');
|
|
41
|
-
const app=get('SELECT ac.*,i.state,a.state app_state,installer.active,installer.role FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?',[credentialId]);
|
|
41
|
+
const app=get('SELECT ac.*,i.state,i.installer_id,a.state app_state,installer.active,installer.role FROM app_credentials ac JOIN app_installations i ON i.id=ac.installation_id JOIN apps a ON a.id=i.app_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE ac.credential_id=?',[credentialId]);
|
|
42
42
|
if(app){
|
|
43
43
|
if(app.state!=='active'||app.app_state!=='active'||!app.active||!['owner','admin'].includes(app.role)||!JSON.parse(app.actions_json).includes('sessions.read'))domainError(403,'forbidden','app session read authorization changed');
|
|
44
44
|
if(get(APP_LINEAGE_REVOKED,[credentialId,now]))domainError(401,'authentication_required','delegation authority revoked');
|
|
45
45
|
appFence=JSON.parse(app.grants_json);
|
|
46
|
+
// `forInstaller` (the view-binding route only): an app its installer consented to `sessions.access` reaches the
|
|
47
|
+
// installer's own sessions to bind them, as that route already lets it act for them (ADR 0034). Every other read,
|
|
48
|
+
// a transcript, its messages and events, keeps the ordinary grants.
|
|
49
|
+
if(args.forInstaller&&appFence===null&&JSON.parse(app.actions_json).includes('sessions.access'))installerFor=app.installer_id;
|
|
46
50
|
if(appFence!==null)grantSource=get('SELECT id,principal_id FROM credentials WHERE id=?',[app.subject_credential_id??app.parent_id]);
|
|
47
51
|
}
|
|
48
52
|
if(!credential||!membership)domainError(401,'authentication_required','authentication required');
|
|
@@ -72,6 +76,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
72
76
|
const readable=bound?Boolean(get('SELECT 1 ok FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?',[teamId,root.id,bound.issuer,bound.room,bound.conversation]))&&family.every(session=>session.deleted_at==null):family.every(session=>{
|
|
73
77
|
if(session.deleted_at!=null)return false;
|
|
74
78
|
if(!appGrantAllows({app_grants:appFence},'session',session.id,'sessions.read'))return false;
|
|
79
|
+
if(installerFor&&session.custodian_id===installerFor)return true;
|
|
75
80
|
if(appFence!==null){
|
|
76
81
|
if(!grantSource)return false;
|
|
77
82
|
try{authorizedSession({teamId,principalId:grantSource.principal_id,credentialId:grantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
|