@volter/supercode-teams 0.2.23 → 0.2.24

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.
Files changed (2) hide show
  1. package/connector/index.mjs +65 -12
  2. package/package.json +1 -1
@@ -6,6 +6,16 @@ import { EffectConsumer } from './effects.mjs';
6
6
  import { NativeEffects } from './native-effects.mjs';
7
7
 
8
8
  const SYNC_PROBLEM_LIMIT=20;
9
+ // Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
10
+ // core.mjs MAX_PENDING_RUNTIME_OPENS; more reject with SUBSCRIPTION_OPEN_CAPACITY) and every live follow is a follower
11
+ // the service polls, while a subtree rule can cover hundreds of sessions. Only the most recently active sessions (at
12
+ // most FOLLOW_LIMIT, half the SDK's limit for headroom, active within FOLLOW_ACTIVE_WINDOW_MS) are followed live; the
13
+ // rest are covered by each beat's stamp-based capture. Followers rotate as activity changes.
14
+ const FOLLOW_LIMIT=32,FOLLOW_ACTIVE_WINDOW_MS=30*60*1000,FOLLOW_RETRY_MS=60*1000;
15
+ const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
16
+ const capacityError=error=>error?.code===CAPACITY_CODE||/Too many unresolved subscriptions/.test(String(error?.message??''));
17
+ // An error of the connection itself (the outbox, authentication, the server), not of one session.
18
+ const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
9
19
  const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
10
20
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
11
21
  export function readSyncStatus(stateDir){
@@ -57,7 +67,7 @@ function atomic(path, value) {
57
67
  }
58
68
  }
59
69
  export class TeamsConnector {
60
- constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
70
+ constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
61
71
  save(){atomic(this.statePath,this.state);}
62
72
  // What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
63
73
  // (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
@@ -158,14 +168,14 @@ export class TeamsConnector {
158
168
  pollEffects() { return this.effects.poll(); }
159
169
  async syncConfiguredSessions(configuration,roots){
160
170
  if(typeof this.core.discover!=='function')return;
161
- this.followers??=new Map();
171
+ this.followers??=new Map();this.followRetry??=new Map();this.stoppingFollowers??=new Set();
162
172
  this.nativeEffects.beginDiscovery();
163
173
  const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
164
- for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){follower.controller.abort();this.followers.delete(key);}}
174
+ for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){this.stopFollower(key);}}
165
175
  // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
166
176
  // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
167
177
  // machine; rules over the same root and harnesses share the scan.
168
- const discoveries=new Map();
178
+ const discoveries=new Map(),followCandidates=[];
169
179
  for(const rule of activeRules.values()){
170
180
  const root=roots.find(item=>item.id===rule.selector.root_id);
171
181
  if(!root)continue;
@@ -193,22 +203,65 @@ export class TeamsConnector {
193
203
  // the pass. The session keeps its native binding either way, so it can still be attached to.
194
204
  let captured = true;
195
205
  if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
196
- if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
206
+ if (connectionError(error) || this.abort.signal.aborted) throw error;
197
207
  captured = false; this.noteSessionProblem(options, error);
198
208
  process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
199
209
  }
200
210
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
201
211
  if (!captured) continue;
202
- const key=`${rule.id}:${harness}:${nativeSessionId}`;
203
- if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
204
- const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
205
- // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
206
- const followed={...options,configuration:null,stamp:undefined};
207
- const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(followed);}})().catch(error=>{if(!controller.signal.aborted)this.noteSessionProblem(followed,error);}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
208
- this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
212
+ followCandidates.push({key:`${rule.id}:${harness}:${nativeSessionId}`,activity:this.sessionActivity(options),options});
209
213
  }
210
214
  }
211
215
  this.nativeEffects.endDiscovery();
216
+ this.rotateFollowers(followCandidates);
217
+ }
218
+ // When a session was last active: its descriptor's updated_at_ms, else its native file's modification time.
219
+ sessionActivity({locator,descriptor}){
220
+ let activity=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
221
+ const path=locator?.storage?.kind==='file'&&typeof locator.storage.path==='string'?locator.storage.path:null;
222
+ if(path)try{activity=Math.max(activity??0,statSync(path).mtimeMs);}catch{}
223
+ return activity;
224
+ }
225
+ // Follows the FOLLOW_LIMIT most recently active candidates (active within the window) and stops every other
226
+ // follower. A capacity refusal is not a session's failure: the connector backs off opening follows for a while.
227
+ rotateFollowers(candidates){
228
+ const now=this.clock.nowMs(),seen=new Set(),wanted=[];
229
+ for(const candidate of candidates.filter(item=>item.activity!==null&&now-item.activity<=FOLLOW_ACTIVE_WINDOW_MS).sort((a,b)=>b.activity-a.activity)){
230
+ if(seen.has(candidate.key)||typeof this.core.session(candidate.options.locator).follow!=='function')continue;
231
+ seen.add(candidate.key);wanted.push(candidate);if(wanted.length>=FOLLOW_LIMIT)break;
232
+ }
233
+ for(const key of this.followers.keys())if(!seen.has(key))this.stopFollower(key);
234
+ for(const [key,until] of this.followRetry)if(until<=now)this.followRetry.delete(key);
235
+ for(const {key,options} of wanted){
236
+ if(this.followers.has(key)||this.followRetry.has(key))continue;
237
+ // A stopped follower holds its slot until it has closed, so live follows never exceed the bound.
238
+ if((this.followBackoffUntil??0)>now||this.followers.size+this.stoppingFollowers.size>=FOLLOW_LIMIT)break;
239
+ this.follow(key,options);
240
+ }
241
+ }
242
+ stopFollower(key){
243
+ const follower=this.followers.get(key);if(!follower)return;
244
+ follower.controller.abort();this.followers.delete(key);
245
+ this.stoppingFollowers.add(follower.task);void follower.task.finally(()=>this.stoppingFollowers.delete(follower.task));
246
+ }
247
+ follow(key,options){
248
+ const {locator}=options,controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
249
+ // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
250
+ const followed={...options,configuration:null,stamp:undefined};
251
+ const task=(async()=>{
252
+ for await(const _event of this.core.session(locator).follow({signal:controller.signal})){
253
+ if(controller.signal.aborted)break;
254
+ // A capture that fails is that session's problem, as on a beat; the follow goes on.
255
+ try{await this.capture(followed);}catch(error){if(controller.signal.aborted)break;if(connectionError(error))this.lastError=error;else this.noteSessionProblem(followed,error);}
256
+ }
257
+ })().catch(error=>{
258
+ if(controller.signal.aborted)return;
259
+ // The follow itself failed: the beat's capture still covers the session, so it is not listed as not syncing.
260
+ // A capacity refusal pauses every new follow for a while; any other failure retries this one after a while.
261
+ if(capacityError(error))this.followBackoffUntil=this.clock.nowMs()+FOLLOW_RETRY_MS;
262
+ else{this.followRetry.set(key,this.clock.nowMs()+FOLLOW_RETRY_MS);this.lastError=error;}
263
+ }).finally(()=>{this.abort.signal.removeEventListener('abort',stop);if(this.followers.get(key)?.controller===controller)this.followers.delete(key);});
264
+ this.followers.set(key,{ruleId:options.ruleId,ruleRevision:options.ruleRevision,controller,task});
212
265
  }
213
266
  // What a session's native store looked like when discovery listed it: its file's size and modification
214
267
  // time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
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",