@volter/supercode-teams 0.2.178 → 0.3.0

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.
@@ -4,6 +4,7 @@ import { randomBytes, createHash } from 'node:crypto';
4
4
  import { canonicalJson, carryDigest } from '../contracts/index.mjs';
5
5
  import { EffectConsumer } from './effects.mjs';
6
6
  import { NativeEffects } from './native-effects.mjs';
7
+ import { consumeAgentClosures } from './agent-closures.mjs';
7
8
 
8
9
  const SYNC_PROBLEM_LIMIT=20;
9
10
  // Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
@@ -172,7 +173,7 @@ export class TeamsConnector {
172
173
  this.state.sequence += 1;
173
174
  const reply = await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
174
175
  incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
175
- bindings: [...this.nativeEffects.published(), ...await bindings()],
176
+ bindings: [...(this.activityBindings?.values()??[]), ...this.nativeEffects.published(), ...await bindings()],
176
177
  roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
177
178
  ...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
178
179
  // What each synced session's harness is doing (its turn: working, idle, …), from the last discovery.
@@ -189,9 +190,19 @@ export class TeamsConnector {
189
190
  beating = next.catch(() => {});
190
191
  return next;
191
192
  };
193
+ this.publishActivityHeartbeat=()=>void heartbeat().catch(error=>{this.lastError=error;});
194
+ this.activityBindings=new Map();
195
+ this.activityEvent=event=>{
196
+ if(event.subscription!==this.activitySubscription)return;
197
+ for(const activity of event.activities??[])this.recordActivityBinding(activity);
198
+ this.publishActivityHeartbeat();
199
+ };
200
+ this.core.on?.('sessionActivityEvent',this.activityEvent);
192
201
  const pass = async () => {
193
202
  const configuration = await this.configuration();
194
203
  await this.syncConfiguredSessions(configuration, this.localRoots);
204
+ await this.publishSessionProfiles();
205
+ await consumeAgentClosures(this);
195
206
  await heartbeat();
196
207
  await this.pollEffects();
197
208
  };
@@ -252,7 +263,7 @@ export class TeamsConnector {
252
263
  // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
253
264
  // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
254
265
  // machine; rules over the same root and harnesses share the scan.
255
- const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[];
266
+ const discoveries=new Map(),followCandidates=[],discovered=new Set(),activity=[],activityLocators=new Map();
256
267
  this.liveFiles??=new Map();
257
268
  this.turnStates??=new Map();
258
269
  for(const rule of activeRules.values()){
@@ -275,6 +286,8 @@ export class TeamsConnector {
275
286
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
276
287
  if(!locator||!nativeSessionId||!harness)continue;
277
288
  discovered.add(`${harness}:${nativeSessionId}`);
289
+ activityLocators.set(`${harness}:${nativeSessionId}`,locator);
290
+ if(descriptor.activity)this.recordActivityBinding({...descriptor.activity,harness,session_id:nativeSessionId});
278
291
  // A session's turn state as its harness reports it, and since when this connector has seen it so.
279
292
  const turn=descriptor.activity?.turn;
280
293
  if(typeof turn==='string'&&turn&&activity.length<4096){const key=`${harness}:${nativeSessionId}`,held=this.turnStates.get(key),since=held?.turn===turn?held.since:this.clock.nowMs();this.turnStates.set(key,{turn,since});activity.push({harness,native_id:nativeSessionId,turn,since});}
@@ -303,11 +316,31 @@ export class TeamsConnector {
303
316
  }
304
317
  for(const [key,options] of this.liveFiles)if(!discovered.has(key)||!activeRules.has(options.ruleId))this.liveFiles.delete(key);
305
318
  this.nativeEffects.endDiscovery();
319
+ await this.followSessionActivity([...activityLocators.values()]);
306
320
  for(const key of this.turnStates.keys())if(!discovered.has(key))this.turnStates.delete(key);
307
321
  this.reportedActivity=activity;
308
322
  this.pruneNativeCache(discovered);
309
323
  this.rotateFollowers(followCandidates);
310
324
  }
325
+ recordActivityBinding(activity){
326
+ if(!activity?.harness||!activity.session_id)return;
327
+ const key=`${activity.harness}:${activity.session_id}`,id=`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${key}`).slice(0,32)}`;
328
+ const previous=this.activityBindings?.get(id),presence=activity.presence;
329
+ const state=presence==='running'?'running':presence==='persisted'&&['running','stopped'].includes(previous?.state)?'stopped':'unknown';
330
+ this.activityBindings?.set(id,{id,machine_id:this.machineId,incarnation_id:this.state.incarnation_id,harness:activity.harness,native_session_id:activity.session_id,native_runtime_id:activity.session_id,state,observed_at:this.clock.nowMs(),native_capabilities:[],evidence:activity.evidence??null,turn:activity.turn??'unknown'});
331
+ }
332
+ async followSessionActivity(locators){
333
+ if(typeof this.core.subscribeSessionActivity!=='function'||typeof this.core.unsubscribeSessionActivity!=='function')return;
334
+ if(!locators.length)return;
335
+ if(locators.length>2048)locators=locators.filter(locator=>this.activityBindings?.get(`activity_${hash(`${this.machineId}:${this.state.incarnation_id}:${locator.harness}:${locator.session_id}`).slice(0,32)}`)?.state==='running').slice(0,2048);
336
+ if(!locators.length)return;
337
+ const fingerprint=locators.map(item=>`${item.harness}:${item.session_id}`).sort().join('\n');
338
+ if(fingerprint===this.activityFingerprint)return;
339
+ const opened=await this.core.subscribeSessionActivity(locators),previous=this.activitySubscription;
340
+ this.activitySubscription=opened.subscription;this.activityFingerprint=fingerprint;
341
+ for(const activity of opened.initial??[])this.recordActivityBinding(activity);
342
+ if(previous)await this.core.unsubscribeSessionActivity(previous);
343
+ }
311
344
  captureViewedFiles(){
312
345
  if(this.abort.signal.aborted)return;
313
346
  this.liveFileCaptures??=new Set();this.liveFileStamps??=new Map();
@@ -619,6 +652,29 @@ export class TeamsConnector {
619
652
  const large=(artifactBefore?.size??0)>=LARGE_CAPTURE_BYTES;
620
653
  const loaded=preloaded?preloaded.loaded:await this.core.session(locator).load(captureRead(harness,large?{timeoutMs:Math.max(30000,Math.ceil(artifactBefore.size/(1024*1024))*1000)}:{})),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
621
654
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
655
+ if(Array.isArray(loaded.resolved_profile_history)&&loaded.resolved_profile_history.length) {
656
+ const seen=this.state.profile_history_seen??={},known=new Set(seen[key]??[]);
657
+ const cutoff=backfillPolicy.mode==='none'?rule.capture_after:backfillPolicy.mode==='since'?backfillPolicy.since:null;
658
+ for(const observation of loaded.resolved_profile_history) {
659
+ if(backfillPolicy.mode==='none'&&!Number.isSafeInteger(cutoff))continue;
660
+ const at=Date.parse(observation.at??'');
661
+ if(Number.isSafeInteger(cutoff)&&(!Number.isSafeInteger(at)||at<cutoff))continue;
662
+ const id=`profile_${hash(canonicalJson([this.sourceId,key,observation.native_key,observation.at,observation.profile])).slice(0,32)}`;
663
+ if(known.has(id))continue;
664
+ known.add(id);
665
+ (this.state.profile_outbox??=[]).push({session:key,profile:observation.profile,observed_at:Number.isSafeInteger(at)?at:this.clock.nowMs(),observation_id:id});
666
+ }
667
+ seen[key]=[...known];this.save();
668
+ }
669
+ if(loaded.resolved_profile){
670
+ const observed=this.state.session_profiles??={},profile=loaded.resolved_profile,digest=hash(canonicalJson(profile));
671
+ if(observed[key]?.digest!==digest){
672
+ observed[key]={digest};
673
+ const latest=(this.state.profile_outbox??[]).filter(item=>item.session===key).at(-1);
674
+ if(!latest||canonicalJson(latest.profile)!==canonicalJson(profile))(this.state.profile_outbox??=[]).push({session:key,profile,observed_at:this.clock.nowMs(),observation_id:`profile_${randomBytes(16).toString('hex')}`});
675
+ this.save();
676
+ }
677
+ }
622
678
  let messages=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});
623
679
  this.labelTurns(key,loaded.messages??loaded.turns??[],messages);
624
680
  if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
@@ -626,7 +682,7 @@ export class TeamsConnector {
626
682
  const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
627
683
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
628
684
  const unchanged=previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]);
629
- if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
685
+ if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
630
686
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
631
687
  // A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
632
688
  // whatever a session holds when first seen: a session begun after the rule is published from its first message.
@@ -717,7 +773,22 @@ export class TeamsConnector {
717
773
  }
718
774
  const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
719
775
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
720
- this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
776
+ this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
777
+ }
778
+ async publishSessionProfile(session){
779
+ const observation=this.state.profile_outbox?.find(item=>item.session===session);if(!observation)return;
780
+ try{
781
+ await this.request('POST',`/machines/${this.machineId}/session-profiles`,{session,profile:observation.profile,observed_at:observation.observed_at,observation_id:observation.observation_id});
782
+ this.state.profile_outbox=this.state.profile_outbox.filter(item=>item!==observation);this.save();
783
+ }catch(error){
784
+ // A launch can be captured before its identity declaration reaches Teams. Retain the observation for retry.
785
+ observation.error=errorSummary(error,this.clock.nowMs());observation.attempted_at=this.clock.nowMs();this.save();
786
+ if(error.status!==409&&error.status!==404)throw error;
787
+ }
788
+ }
789
+ async publishSessionProfiles(){
790
+ const first=new Map();for(const item of this.state.profile_outbox??[])if(!first.has(item.session))first.set(item.session,item);
791
+ for(const item of [...first.values()].sort((a,b)=>(a.attempted_at??0)-(b.attempted_at??0)).slice(0,100))await this.publishSessionProfile(item.session);
721
792
  }
722
793
  flush() {
723
794
  const work=this.work.then(()=>this.flushSerial());
@@ -826,5 +897,5 @@ export class TeamsConnector {
826
897
  }
827
898
  async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}if(error.status===400&&error.code==='invalid_request'||error.status===413){this.lastError=error;this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id??null},error);(this.droppedCaptures??=new Set()).add(`${item.begin.harness}:${item.begin.native_session_id}`);process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused${error.status===413?' (one message is larger than a request may be)':' as malformed'}: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(!item.begin.carry)delete next.carry_refused;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();// A queued capture is often flushed by another session's capture: its own session's problem ends with it.
828
899
  this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
829
- stop(){try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
900
+ stop(){this.core.off?.('sessionActivityEvent',this.activityEvent);if(this.activitySubscription)void this.core.unsubscribeSessionActivity(this.activitySubscription).catch(()=>{});try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.liveCaptureTimer)clearInterval(this.liveCaptureTimer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
830
901
  }
package/context.mjs CHANGED
@@ -1,34 +1 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
2
- import { randomBytes } from 'node:crypto';
3
- import { join, dirname } from 'node:path';
4
- import { TeamsClient } from './workspace-client.mjs';
5
- const NAME=/^[a-z][a-z0-9_-]{0,63}$/;
6
- const ID=/^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
7
- function normalizeOrigin(value){const url=new URL(value);if(url.username||url.password||url.pathname!=='/'||url.search||url.hash)throw new Error('server must be a bare HTTP(S) origin');if(url.protocol!=='https:'&&!(url.protocol==='http:'&&['127.0.0.1','::1','[::1]','localhost'].includes(url.hostname)))throw new Error('non-loopback servers require HTTPS');return url.origin;}
8
- export function workspacePaths(supercodeHome){const root=join(supercodeHome,'teams','workspaces');return {root,contexts:join(root,'contexts.json'),credentials:join(root,'credentials'),connectors:join(root,'connectors')};}
9
- function locked(path,fn){mkdirSync(dirname(path),{recursive:true,mode:0o700});chmodSync(dirname(path),0o700);const lock=path+'.lock';let fd;try{fd=openSync(lock,'wx',0o600);}catch(e){throw Object.assign(new Error(`${path} is locked by another writer`),{code:'EBUSY',cause:e});}try{return fn();}finally{closeSync(fd);unlinkSync(lock);}}
10
- function read(path,fallback){return existsSync(path)?JSON.parse(readFileSync(path,'utf8')):fallback;}
11
- function atomic(path,value){const tmp=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(tmp,'wx',0o600);try{writeFileSync(fd,JSON.stringify(value,null,2)+'\n');}finally{closeSync(fd);}try{renameSync(tmp,path);}catch(error){try{unlinkSync(tmp);}catch{}throw error;}chmodSync(path,0o600);}
12
- export class WorkspaceContexts { constructor(supercodeHome){this.paths=workspacePaths(supercodeHome);} read(){return read(this.paths.contexts,{version:1,current:null,contexts:{}});} create(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();if(doc.contexts[name])throw new Error(`context ${name} already exists`);doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});} use(name){return locked(this.paths.contexts,()=>{const doc=this.read();if(name!==null&&name!=='local'&&!doc.contexts[name])throw new Error(`unknown context ${name}`);doc.current=name;atomic(this.paths.contexts,doc);});} remove(name){return locked(this.paths.contexts,()=>{const doc=this.read();delete doc.contexts[name];if(doc.current===name)doc.current=null;atomic(this.paths.contexts,doc);});} writeCredential(id,value){mkdirSync(this.paths.credentials,{recursive:true,mode:0o700});locked(join(this.paths.credentials,`${id}.json`),()=>atomic(join(this.paths.credentials,`${id}.json`),value));} credential(id){return read(join(this.paths.credentials,`${id}.json`),null);} }
13
- WorkspaceContexts.prototype.upsert=function(name,value){if(!NAME.test(name)||name==='local')throw new Error('context name must match [a-z][a-z0-9_-]{0,63} and cannot be local');return locked(this.paths.contexts,()=>{const doc=this.read();doc.contexts[name]=value;atomic(this.paths.contexts,doc);return value;});};
14
- WorkspaceContexts.prototype.createAuthenticated=async function(name,{origin,teamId,credentialRef,fetch}){
15
- if(!ID.test(teamId)||!/^cred_[A-Za-z0-9_-]+$/.test(credentialRef))throw new Error('team and credential reference must be immutable IDs');
16
- const normalized=normalizeOrigin(origin),credential=this.credential(credentialRef);
17
- if(!credential?.token)throw Object.assign(new Error(`credential ${credentialRef} is missing`),{code:'authentication_required'});
18
- if(credential.origin!==normalized)throw Object.assign(new Error('credential origin does not match requested server'),{code:'authentication_required'});
19
- const client=new TeamsClient({server:normalized,credential:credential.token,fetch});
20
- const me=await client.me();
21
- if(credential.server_id&&credential.server_id!==me.server_id)throw Object.assign(new Error('credential server audience does not match'),{code:'authentication_required'});
22
- const membership=me.teams.find(item=>item.team.id===teamId);
23
- if(!membership)throw Object.assign(new Error('credential is not a member of the requested team'),{code:'forbidden'});
24
- return this.create(name,{origin:normalized,server_id:me.server_id,team_id:teamId,principal_id:me.principal.id,credential_ref:credentialRef});
25
- };
26
- WorkspaceContexts.prototype.connector=function(serverId,teamId){
27
- if(!ID.test(serverId)||!ID.test(teamId))throw new Error('server and team must be immutable IDs');
28
- const directory=join(this.paths.connectors,serverId,teamId),path=join(directory,'enrollment.json');
29
- return {directory,read:()=>read(path,null),write:value=>{mkdirSync(directory,{recursive:true,mode:0o700});locked(path,()=>atomic(path,value));},update:fn=>{mkdirSync(directory,{recursive:true,mode:0o700});return locked(path,()=>{const value=fn(read(path,null));if(value===null){if(existsSync(path))unlinkSync(path);}else atomic(path,value);return value;});},remove:()=>{if(existsSync(path))locked(path,()=>unlinkSync(path));}};
30
- };
31
- export function fromContext({supercodeHome,name,fetch}){const contexts=new WorkspaceContexts(supercodeHome),doc=contexts.read(),selected=name??doc.current;if(!selected||selected==='local')throw new Error('a remote workspace context is required');const context=doc.contexts[selected];if(!context)throw new Error(`unknown context ${selected}`);const credential=contexts.credential(context.credential_ref);if(!credential?.token)throw Object.assign(new Error(`credential ${context.credential_ref} is missing`),{code:'authentication_required'});if(credential.server_id!==context.server_id||credential.origin!==context.origin)throw Object.assign(new Error('credential audience does not match context'),{code:'authentication_required'});return {client:new TeamsClient({server:context.origin,credential:credential.token,fetch}),context};}
32
- /** A context name from a team's name: `Volter` → `volter`. */
33
- export function contextNameOf(team){const name=String(team).toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^[^a-z]+/,'').slice(0,64);return name||'team';}
34
- export async function loginContext({supercodeHome,name,origin,teamId,token,invite,identityToken=null,as,fetch}){if(token!=null&&(invite!=null||identityToken!=null))throw new Error('a token signs in alone');if(token==null&&invite==null&&identityToken==null)throw new Error('a token, an invitation or an identity is required');origin=normalizeOrigin(origin);let credential,serverId,principalId,resolvedTeam=teamId;if(invite){const response=await fetch(new URL('/api/v1/invitations/accept',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({enrollment_code:invite,...(identityToken?{access_token:identityToken}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok)throw new Error(`invitation failed: HTTP ${response.status}`);const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}else if(identityToken){const response=await fetch(new URL('/api/v1/login/volter',origin),{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token:identityToken,...(teamId?{team_id:teamId}:{})})});if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw new Error('cross-origin redirects are refused');if(!response.ok){const detail=await response.json().catch(()=>null);throw new Error(`sign-in failed: ${detail?.error?.message??`HTTP ${response.status}`}`);}const value=await response.json();credential=value.credential;principalId=value.principal.id;resolvedTeam=value.team.id;token=credential.token;}const client=new TeamsClient({server:origin,credential:token,fetch});const me=await client.me();serverId=me.server_id;const membership=me.teams.find(x=>x.team.id===resolvedTeam||x.team.name===resolvedTeam);if(!membership)throw new Error('credential is not a member of the requested team');name??=contextNameOf(membership.team.name);principalId??=me.principal.id;const store=new WorkspaceContexts(supercodeHome);const credentialId=`cred_${crypto.randomUUID().replaceAll('-','')}`;store.writeCredential(credentialId,{token,kind:credential?.kind??'user',expires_at:credential?.expires_at??null,server_id:serverId,origin});const saved=store.upsert(name,{origin,server_id:serverId,team_id:membership.team.id,team_name:membership.team.name,principal_id:principalId,credential_ref:credentialId,label:as??null});if(!store.read().current)store.use(name);return saved;}
1
+ export * from "@volter/teams/context";
@@ -0,0 +1 @@
1
+ export { eventClient } from "@volter/teams/event-client";
package/machine-door.mjs CHANGED
@@ -1,36 +1 @@
1
- // Which door a machine verb goes through: without a machine name, this OS
2
- // user's own daemon over its local socket; with one, the Teams server of the
3
- // selected context, which forwards the channel to that enrolled machine.
4
- import { resolve } from 'node:path';
5
- import { openLocalMachine, openRemoteMachine } from './client.mjs';
6
- import { WorkspaceContexts, fromContext } from './context.mjs';
7
- import { teamsHome, paths } from '@volter/teams/home';
8
-
9
- export async function openMachine({ machine = null, context = null, env = process.env, home = teamsHome(env) } = {}) {
10
- if (!machine) return openLocalMachine(paths(home).socket);
11
- const supercodeHome = env.SUPERCODE_HOME ?? resolve(home, '..');
12
- const contexts = new WorkspaceContexts(supercodeHome);
13
- const name = context ?? env.SUPERCODE_CONTEXT ?? contexts.read().current;
14
- if (!name || name === 'local') throw Object.assign(new Error('a remote machine is reached through a Teams context: `supercode teams login …`, then `supercode context use NAME` or --context NAME'), { code: 'invalid_request' });
15
- const { client, context: selected } = fromContext({ supercodeHome, name, fetch: globalThis.fetch });
16
- const credential = contexts.credential(selected.credential_ref);
17
- let machineId = machine;
18
- if (!machine.startsWith('mach_')) {
19
- const listed = (await client.team(selected.team_id).machines.list()).items;
20
- let matches = listed.filter((item) => item.name === machine);
21
- // A mail address names a machine by its short, lowercased host name
22
- // (`yuerans-macbook-pro`), while enrollment defaults to the full one
23
- // (`Yuerans-MacBook-Pro.local`): fall back to that normal form.
24
- if (!matches.length) matches = listed.filter((item) => normalMachineName(item.name) === normalMachineName(machine));
25
- if (matches.length !== 1) throw Object.assign(new Error(matches.length ? `machine name ${machine} is ambiguous; use its id` : `machine ${machine} is not accessible`), { code: matches.length ? 'ambiguous_reference' : 'not_found' });
26
- machineId = matches[0].id;
27
- }
28
- const channel = await openRemoteMachine({ origin: selected.origin, teamId: selected.team_id, machineId, token: credential.token });
29
- channel.machineName = machine;
30
- return channel;
31
- }
32
-
33
- /** A machine name in the form mail addresses use: the short host name, lowercased, with anything outside [a-z0-9_-] as `-`. */
34
- export function normalMachineName(name) {
35
- return String(name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
36
- }
1
+ export { openMachine, normalMachineName } from "@volter/teams/machine-door";