@volter/supercode-teams 0.2.169 → 0.2.171

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.
@@ -14,6 +14,13 @@ export function syncStatusLines(sync, { indent = ' ' } = {}) {
14
14
  for (const item of problems.slice(0, SHOWN)) lines.push(`${indent}${item.harness} ${item.session_id}${item.rule_id ? ` · ${item.rule_id}` : ''} · ${when(item.at)} · ${reason(item)}`);
15
15
  if (problems.length > SHOWN) lines.push(`${indent}… and ${problems.length - SHOWN} more (--json lists them all)`);
16
16
  } else lines.push('Not syncing none');
17
+ const unbound = sync.not_bound ?? [];
18
+ // A live session that is not bound cannot be typed into from the team; each says why.
19
+ if (unbound.length) {
20
+ const count = sync.not_bound_count ?? unbound.length;
21
+ lines.push(`Not bound (${count} live session${count === 1 ? '' : 's'}${count > unbound.length ? `, ${unbound.length} shown` : ''}):`);
22
+ for (const item of unbound.slice(0, SHOWN)) lines.push(`${indent}${item.harness} ${item.session_id} · ${item.reason}`);
23
+ }
17
24
  if (sync.queued_captures) lines.push(`Queued ${sync.queued_captures} capture${sync.queued_captures === 1 ? '' : 's'} waiting for the server`);
18
25
  if (sync.last_error) lines.push(`Last error ${when(sync.last_error.at)} · ${reason(sync.last_error)}`);
19
26
  return lines;
package/bin/workspace.mjs CHANGED
@@ -249,7 +249,7 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
249
249
  say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
250
250
  // Only trouble (a session not syncing, an error in the last day) is worth lines here; `teams status` shows the rest.
251
251
  const sync = account.machine?.sync;
252
- if (sync?.not_syncing.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
252
+ if (sync?.not_syncing.length || sync?.not_bound?.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
253
253
  return;
254
254
  }
255
255
  if (sub === 'switch') {
@@ -66,7 +66,7 @@ export interface ConnectorConfigurationValue {
66
66
  export interface SyncErrorSummary { code: string | null; message: string; at: number }
67
67
  export interface SyncProblem extends SyncErrorSummary { harness: string; session_id: string; rule_id: string | null }
68
68
  /** What `supercode teams status` shows: sessions not syncing (most recent first) and the last error; null without state. */
69
- export declare function readSyncStatus(stateDir: string): { not_syncing: SyncProblem[]; last_error: SyncErrorSummary | null; queued_captures: number } | null;
69
+ export declare function readSyncStatus(stateDir: string): { not_syncing: SyncProblem[]; not_bound: { harness: string; session_id: string; reason: string }[]; not_bound_count: number; last_error: SyncErrorSummary | null; queued_captures: number } | null;
70
70
 
71
71
  export declare class TeamsConnector {
72
72
  constructor(options: TeamsConnectorOptions);
@@ -24,7 +24,7 @@ const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='strin
24
24
  export function readSyncStatus(stateDir){
25
25
  let state;try{state=JSON.parse(readFileSync(join(stateDir,'connector.json'),'utf8'));}catch{return null;}
26
26
  const status=state?.sync_status;
27
- return {not_syncing:Array.isArray(status?.problems)?status.problems:[],last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:0};
27
+ return {not_syncing:Array.isArray(status?.problems)?status.problems:[],not_bound:Array.isArray(status?.unbound)?status.unbound:[],not_bound_count:Number.isSafeInteger(status?.unbound_count)?status.unbound_count:(Array.isArray(status?.unbound)?status.unbound.length:0),last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:0};
28
28
  }
29
29
  // A tool call still running when the core reads a session has no result yet; the core answers it with a synthesized
30
30
  // placeholder (crates/interchange session/helpers.rs ensure_tool_results_paired: a `tool` message for that call id,
@@ -139,6 +139,8 @@ export class TeamsConnector {
139
139
  this.lastErrorValue=error;const known=sameError(status.last_error,summary);if(!known)status.last_error=summary;
140
140
  if(!listed||!known)this.saveQuietly();
141
141
  }
142
+ /** The live sessions that could not be bound, and why, kept for `teams status`; written only when the list changes. */
143
+ noteUnbound(){const status=this.syncStatus(),next=this.nativeEffects.notBound(SYNC_PROBLEM_LIMIT),count=this.nativeEffects.unbound.size;if(JSON.stringify(status.unbound??[])===JSON.stringify(next)&&status.unbound_count===count)return;status.unbound=next;status.unbound_count=count;this.saveQuietly();}
142
144
  clearSessionProblem(harness,nativeSessionId){
143
145
  const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
144
146
  status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
@@ -179,6 +181,9 @@ export class TeamsConnector {
179
181
  this.lastHeartbeatAt = this.clock.nowMs();
180
182
  // The sessions someone is following now (a viewer or channel open on them) skip the capture floors.
181
183
  if (Array.isArray(reply?.watched)) this.watched = new Set(reply.watched.map(item => `${item.harness}:${item.native_id}`));
184
+ // A binding the server could not store (no session with its native id) is said, not lost.
185
+ this.nativeEffects.refused(reply?.unbound_bindings);
186
+ this.noteUnbound();
182
187
  this.save();
183
188
  });
184
189
  beating = next.catch(() => {});
@@ -8,19 +8,30 @@ const within = (root, path) => { const name = relative(root, path); return name
8
8
  const LIMIT = 1024 * 1024;
9
9
 
10
10
  export class NativeEffects {
11
- constructor(connector) { this.c = connector; this.bindings = new Map(); this.attachments = new Map(); this.ownedRuntimes = new Set(); }
11
+ constructor(connector) { this.c = connector; this.bindings = new Map(); this.attachments = new Map(); this.ownedRuntimes = new Set(); this.unbound = new Map(); }
12
12
  async discover(descriptor, ruleId) {
13
13
  const c = this.c, locator = descriptor.locator;
14
- if (!descriptor.live_endpoint?.startsWith('supercode-live://') || !descriptor.cwd || !c.core.attachManagedRuntime || !c.core.runtimeCapabilities) return;
14
+ if (!descriptor.live_endpoint?.startsWith('supercode-live://')) return;
15
+ // A session with a live endpoint that ends up unbound says why (`teams status`, Not bound): it cannot be typed into.
16
+ const key = `${locator.harness}:${locator.session_id}`, unbound = reason => { this.unbound.set(key, { harness: locator.harness, session_id: locator.session_id, reason, seen: true }); };
17
+ if (!descriptor.cwd) return unbound('the session names no working folder');
18
+ if (!c.core.attachManagedRuntime || !c.core.runtimeCapabilities) return unbound('this harness client cannot attach to live runtimes');
15
19
  const id = `bind_${digest(`${c.machineId}:${c.state.incarnation_id}:${locator.harness}:${locator.session_id}`).slice(0,32)}`;
16
20
  const params = { harness: locator.harness, runtime_id: locator.session_id, cwd: descriptor.cwd, base_url: descriptor.live_endpoint };
17
21
  const existing = this.bindings.get(id);
18
- if (existing && JSON.stringify(existing.params) === JSON.stringify(params)) { existing.seen = true; existing.ruleId = ruleId; return; }
19
- const { capabilities } = await c.core.runtimeCapabilities(params);
20
- if (!capabilities.attach_existing_process || !capabilities.stream_events) return;
21
- // A live endpoint whose runtime is gone (its receipt's process ended) has nothing to bind: that is quiet.
22
+ if (existing && JSON.stringify(existing.params) === JSON.stringify(params)) { existing.seen = true; existing.ruleId = ruleId; this.unbound.delete(key); return; }
23
+ let capabilities;
24
+ try { ({ capabilities } = await c.core.runtimeCapabilities(params)); } catch (error) { return unbound(`reading its runtime's capabilities failed: ${String(error?.message ?? error).slice(0, 200)}`); }
25
+ const missing = ['attach_existing_process', 'stream_events'].filter(name => !capabilities[name]);
26
+ if (missing.length) return unbound(`its runtime does not offer ${missing.join(' or ')}`);
27
+ // A live endpoint whose runtime is gone (its receipt's process ended) has nothing to bind: that is quiet, and the
28
+ // next discovery no longer offers it. Any other failure to attach is said.
22
29
  let runtime;
23
- try { runtime = await c.core.attachManagedRuntime(params); } catch { return; }
30
+ try { runtime = await c.core.attachManagedRuntime(params); } catch (error) {
31
+ const message = String(error?.message ?? error);
32
+ if (error?.code === 'not_found' || error?.sdkCode === 'not_found') { this.unbound.delete(key); return; }
33
+ return unbound(`attaching to its runtime failed: ${message.slice(0, 200)}`);
34
+ }
24
35
  try {
25
36
  const actions = ['attach_observe'];
26
37
  if (capabilities.send_input) actions.push('interact');
@@ -31,10 +42,21 @@ export class NativeEffects {
31
42
  native_runtime_id: runtime.handle.runtime_id, native_session_id: locator.session_id,
32
43
  harness: locator.harness, state: 'running', observed_at: c.clock.nowMs(), native_capabilities: actions,
33
44
  } });
45
+ this.unbound.delete(key);
34
46
  } finally { await runtime.close(); } // This backend's close detaches the observer only.
35
47
  }
36
- beginDiscovery() { for (const binding of this.bindings.values()) binding.seen = false; }
37
- endDiscovery() { for (const [id, binding] of this.bindings) if (!binding.seen) this.bindings.delete(id); }
48
+ /** The server said it holds no session for these bindings: they stay unbound, and say so. */
49
+ refused(items) {
50
+ for (const item of items ?? []) {
51
+ const binding = this.bindings.get(item.id);
52
+ if (!binding) continue;
53
+ this.unbound.set(`${binding.descriptor.harness}:${binding.descriptor.native_session_id}`, { harness: binding.descriptor.harness, session_id: binding.descriptor.native_session_id, reason: 'the server holds no session with this native id from this machine', seen: true });
54
+ }
55
+ }
56
+ /** Every live session that is not bound, and why. */
57
+ notBound(limit = 50) { return [...this.unbound.values()].slice(0, limit).map(({ seen, ...item }) => item); }
58
+ beginDiscovery() { for (const binding of this.bindings.values()) binding.seen = false; for (const item of this.unbound.values()) item.seen = false; }
59
+ endDiscovery() { for (const [id, binding] of this.bindings) if (!binding.seen) this.bindings.delete(id); for (const [key, item] of this.unbound) if (!item.seen) this.unbound.delete(key); }
38
60
  published() { return [...this.bindings.values()].map(binding => ({ ...binding.descriptor, observed_at: this.c.clock.nowMs() })); }
39
61
  async prepare(effect) {
40
62
  if (effect.kind === 'continue') return this.prepareContinuation(effect);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.169",
3
+ "version": "0.2.171",
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",
@@ -136,8 +136,22 @@ const managerGuard=(auth,sessionId,now,{governance=false}={})=>({
136
136
  async function resolve(api,auth,ref){const head='SELECT s.*,v.descriptor_json,v.sort_at FROM sessions s JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? AND ',native=/^([a-z][a-z0-9-]*):(.+)$/.exec(ref);let rows;if(native)rows=await api.store.query(head+'s.id IN (SELECT session_id FROM origins WHERE team_id=? AND harness=? AND native_id=? AND deleted_at IS NULL)',[auth.team_id,auth.team_id,native[1],native[2]]);else if(ref.length>=36)rows=await api.store.query(head+'s.id=?',[auth.team_id,ref]);else if(ref.length>=8)rows=await api.store.query(head+'s.id LIKE ?',[auth.team_id,`${ref}%`]);else rows=[];const visible=[];for(let r of rows){r=await canonicalSession(api,auth.team_id,r);r=await api.store.one('SELECT s.*,v.descriptor_json,v.sort_at FROM sessions s JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? AND s.id=?',[auth.team_id,r.id]);if(r&&await canReadMerged(api,auth,r)&&!visible.some(x=>x.id===r.id))visible.push(r);}if(visible.length>1)fail(400,'ambiguous_reference','ambiguous session reference');if(!visible.length)fail(404,'not_found','not found');return visible[0];}
137
137
  function qobj(url){return Object.fromEntries([...url.searchParams].map(([k,v])=>[k,['limit','since','until','tail','revision'].includes(k)?Number(v):k==='include_children'?v==='true':v]));}
138
138
  function projection(row){return JSON.parse(row.descriptor_json);}
139
- async function readerProjection(api,auth,row){
139
+ /**
140
+ * The native bindings sessions have now (contracts: Descriptor.bindings), by session id: each one a machine reported in
141
+ * its latest heartbeat, from the incarnation it runs as, on a machine still enrolled and seen within the window attach
142
+ * uses (effects.mjs), so a read offers only bindings attach would accept. Only the contract's fields are served. The
143
+ * captured descriptor never holds them; a heartbeat replaces them, so every read takes them fresh.
144
+ */
145
+ const BINDING_FIELDS=['id','machine_id','incarnation_id','native_session_id','native_runtime_id','harness','state','observed_at','native_capabilities'];
146
+ async function liveBindings(api,teamId,sessionId=null){
147
+ const rows=await api.store.query(`SELECT b.session_id,b.descriptor_json FROM bindings b JOIN machines m ON m.team_id=b.team_id AND m.id=b.machine_id AND m.incarnation_id=b.incarnation_id AND m.revoked_at IS NULL AND m.last_seen_at>? WHERE b.team_id=?${sessionId?' AND b.session_id=?':''} ORDER BY b.id`,[api.clock.nowMs()-45000,teamId,...(sessionId?[sessionId]:[])]);
148
+ const bySession=new Map();
149
+ for(const row of rows){const reported=JSON.parse(row.descriptor_json),binding=Object.fromEntries(BINDING_FIELDS.filter(field=>field in reported).map(field=>[field,reported[field]]));const list=bySession.get(row.session_id)??[];list.push(binding);bySession.set(row.session_id,list);}
150
+ return bySession;
151
+ }
152
+ async function readerProjection(api,auth,row,bindings=null){
140
153
  const view=projection(row);
154
+ view.bindings=(bindings??await liveBindings(api,auth.team_id,row.id)).get(row.id)??[];
141
155
  for(const field of ['parent_id','continued_from']){
142
156
  const reference=view[field];
143
157
  if(!reference)continue;
@@ -177,11 +191,11 @@ async function listSessions(api,res,auth,url,requestId,search){
177
191
  }else tuples=rows.filter(row=>row.revision===row.version_revision).map(row=>[row.id,row.version_revision]);
178
192
  const rowByVersion=new Map(rows.map(row=>[`${row.id}:${row.version_revision}`,row])),grantsBySession=new Map();
179
193
  for(const grant of allGrants){const list=grantsBySession.get(grant.session_id)??[];list.push(grant);grantsBySession.set(grant.session_id,list);}
180
- const hits=[],hitTuples=[];
194
+ const hits=[],hitTuples=[],bindingsBySession=await liveBindings(api,auth.team_id);
181
195
  for(const [id,revision]of tuples){
182
196
  const row=rowByVersion.get(`${id}:${revision}`),current=currentById.get(id);
183
197
  if(!row||!current||current.canonical_id||!await canReadMerged(api,{...auth,now:boundaryNow},current))continue;
184
- const view=await readerProjection(api,auth,row);
198
+ const view=await readerProjection(api,auth,row,bindingsBySession);
185
199
  if(query.user&&(query.user==='me'?auth.principal_id:query.user)!==view.user?.principal_id)continue;
186
200
  if(query.repo&&query.repo!==view.repository?.key)continue;
187
201
  if(query.harness&&query.harness!==view.harness)continue;
@@ -226,7 +240,7 @@ async function activityOf(api,teamId,sessionId){
226
240
  async function sessionDescriptor(api,res,auth,id,requestId){
227
241
  const row=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),bound:auth.bound});
228
242
  const activity=await activityOf(api,auth.team_id,row.id);
229
- send(res,200,result(api,auth,{...JSON.parse(row.descriptor_json),turnActivity:activity,working:activity.state==='working'?{since:activity.since}:null}),requestId,{ETag:`"${row.revision}"`});
243
+ send(res,200,result(api,auth,{...JSON.parse(row.descriptor_json),bindings:(await liveBindings(api,auth.team_id,row.id)).get(row.id)??[],turnActivity:activity,working:activity.state==='working'?{since:activity.since}:null}),requestId,{ETag:`"${row.revision}"`});
230
244
  }
231
245
  async function correlateRoute(api,req,res,auth,path,requestId){
232
246
  if(!['user','service'].includes(auth.kind)||auth.role==='viewer')fail(403,'forbidden','credential cannot correlate sessions');
@@ -252,6 +266,7 @@ async function readSession(api,res,auth,id,url,requestId){
252
266
  const {rows,beforeCursor,revision}=snapshot;
253
267
  const messages=rows.map(row=>({id:row.id,source_id:row.source_id,native_key:row.native_key,recorded_at:row.recorded_at,value:JSON.parse(row.value_json)}));
254
268
  const descriptor=JSON.parse(snapshot.descriptor_json);
269
+ descriptor.bindings=(await liveBindings(api,auth.team_id,descriptor.id)).get(descriptor.id)??[];
255
270
  const activity=await activityOf(api,auth.team_id,descriptor.id);
256
271
  send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},descriptor,turnActivity:activity,working:activity.state==='working'?{since:activity.since}:null,names:await sessionNames(api,auth.team_id,descriptor),messages,before_cursor:beforeCursor,event_cursor:snapshot.eventCursor,revision,display:{returned:messages.length,total:snapshot.total,truncated_fields:0}},requestId);
257
272
  }