@volter/supercode-teams 0.2.24 → 0.2.26

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.
@@ -11,6 +11,7 @@ const VALUES = new Set([
11
11
  'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
12
  'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
13
13
  'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'delegated-issuer', 'workplace', 'organization', 'key', 'room',
14
+ 'conversation', 'rh2',
14
15
  ]);
15
16
 
16
17
  export function parseArgs(argv) {
package/bin/workspace.mjs CHANGED
@@ -299,6 +299,15 @@ async function sessionsCommand(sub, command) {
299
299
  if (sub === 'export') return exportSession(session, command);
300
300
  if (sub === 'continue') return continueSession(session, command);
301
301
  if (sub === 'url') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions url REF'); const descriptor = await session.descriptor(); return output({ version: 1, context: scope(context), data: { url: `${context.origin}/teams/${context.team_id}/sessions/${descriptor.data.id}` } }); }
302
+ // A session view (RH2 ADR 0008): its owner lets a workplace Room's Conversation read this session with the tokens that
303
+ // workplace signs. `bind` records it and prints what the workplace's own bind door takes; `unbind` removes it.
304
+ if (sub === 'bind' || sub === 'unbind') {
305
+ only(flags, ['json', 'help', 'context', 'room', 'conversation', 'rh2']); requireArgs(rest, 1, 1, `sessions ${sub} REF --room ROOM --conversation CONVERSATION --rh2 ORIGIN`);
306
+ if (!flags.room || !flags.conversation || !flags.rh2) usage(`sessions ${sub} needs --room, --conversation and --rh2 (the workplace's origin)`);
307
+ const body = { issuer: new URL(String(flags.rh2)).origin, room: String(flags.room), conversation: String(flags.conversation) };
308
+ const bound = await command.client.request(sub === 'bind' ? 'POST' : 'DELETE', `${session.path}/view-bindings`, { body });
309
+ return output({ version: 1, context: scope(context), data: { teamsUrl: new URL(context.origin).origin, team: context.team_id, sessionId: bound.data.session_id, harness: bound.data.harness, principalId: context.principal_id, bindings: bound.data.bindings } });
310
+ }
302
311
  if (sub === 'rename') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 2, 2, 'sessions rename REF TITLE'); const descriptor = await session.descriptor(); return output(await command.client.request('PATCH', session.path, { body: { title: rest[1] }, ifMatch: descriptor.data.revision })); }
303
312
  if (sub === 'delete') { only(flags, ['json', 'help', 'context']); requireArgs(rest, 1, 1, 'sessions delete REF'); const descriptor = await session.descriptor(); await command.client.request('DELETE', session.path, { ifMatch: descriptor.data.revision }); return output({ version: 1, context: scope(context), data: null }); }
304
313
  unsupported(`sessions ${sub} is reserved for a later arc`);
@@ -860,9 +869,14 @@ function printHelp() {
860
869
  sources create|list|status|pause|revoke|credentials|config
861
870
  machines list|roots|access|authorize-continuation
862
871
  sync add|list|show|pause|resume|remove
863
- sessions list|search|show|follow|access|rename|delete|url|attach|export|continue
872
+ sessions list|search|show|follow|access|rename|delete|url|attach|export|continue|bind|unbind
864
873
  Use --help with a command for its required arguments and options.
865
874
  `);
866
875
  }
867
876
  export function errorExit(error) { return error.exitCode ?? ERROR_EXITS[error.code] ?? 1; }
868
- if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.exit(errorExit(error)); });
877
+ if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.exitCode = errorExit(error);
878
+ // Drain rather than process.exit(): on Windows, exiting while a just-used fetch connection is still closing aborts
879
+ // Node inside libuv (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and loses the exit code. Whatever
880
+ // still holds the event loop open gets a moment, then the exit is forced.
881
+ setTimeout(() => process.exit(process.exitCode), 2_000).unref();
882
+ });
@@ -36,6 +36,12 @@ export interface TeamsConnectorOptions {
36
36
  effectExecutor?: (effect: NativeEffect, context: {core: ConnectorCore; signal: AbortSignal}) => Promise<{native_session_id?: string | null; runtime_id?: string | null} | void>;
37
37
  clock?: ConnectorClock;
38
38
  fetch?: typeof globalThis.fetch;
39
+ /** Milliseconds between heartbeats, sent on their own timer independent of the capture pass (default 15000). */
40
+ heartbeatMs?: number;
41
+ /** A native file at least nativeIdleBytes long gets its native bundle only after this long without a write (default 60000). */
42
+ nativeIdleMs?: number;
43
+ /** Size from which a native file's bundle waits for it to be idle (default 8 MiB). */
44
+ nativeIdleBytes?: number;
39
45
  }
40
46
 
41
47
  export interface CaptureOptions {
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import { randomBytes, createHash } from 'node:crypto';
4
4
  import { canonicalJson } from '../contracts/index.mjs';
@@ -10,12 +10,15 @@ const SYNC_PROBLEM_LIMIT=20;
10
10
  // core.mjs MAX_PENDING_RUNTIME_OPENS; more reject with SUBSCRIPTION_OPEN_CAPACITY) and every live follow is a follower
11
11
  // the service polls, while a subtree rule can cover hundreds of sessions. Only the most recently active sessions (at
12
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;
13
+ // rest are covered by each beat's stamp-based capture. Followers rotate as activity changes, with hysteresis: a
14
+ // follower is kept while it stays in the window and ranks within FOLLOW_KEEP_RANK, so sessions near the cutoff do not
15
+ // stop and reopen (each reopen a full load and capture) on alternate beats; new follows only fill up to FOLLOW_LIMIT.
16
+ const FOLLOW_LIMIT=32,FOLLOW_KEEP_RANK=40,FOLLOW_ACTIVE_WINDOW_MS=30*60*1000,FOLLOW_RETRY_MS=60*1000;
15
17
  const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
16
18
  const capacityError=error=>error?.code===CAPACITY_CODE||/Too many unresolved subscriptions/.test(String(error?.message??''));
17
19
  // An error of the connection itself (the outbox, authentication, the server), not of one session.
18
20
  const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
21
+ const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
19
22
  const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
20
23
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
21
24
  export function readSyncStatus(stateDir){
@@ -49,6 +52,15 @@ function settledToolTail(previous,messages){
49
52
  return messages.slice(0,count).map(digestOf);
50
53
  }
51
54
  const hash=value=>createHash('sha256').update(value).digest('hex');
55
+ const CHUNK_BYTES=1024*1024;
56
+ // A native file at least NATIVE_IDLE_BYTES long gets its native bundle only once it has gone NATIVE_IDLE_MS
57
+ // without a write. Translating reads the whole file, takes minutes for a 100 MB transcript, and holds the
58
+ // harness server's only request reader while it runs; a live Claude Code transcript is appended to on every
59
+ // message and tool result, so translating it mid-turn is wasted (the file has moved by the time it finishes).
60
+ // A minute is four beats with no write: the turn is over, or waiting on the user or one long tool call.
61
+ // Smaller files translate in well under a second and keep getting a bundle with every capture.
62
+ const NATIVE_IDLE_MS=60000,NATIVE_IDLE_BYTES=8*1024*1024;
63
+ const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
52
64
  const recordedAt=entry=>{if(Number.isSafeInteger(entry.recorded_at))return entry.recorded_at;if(Number.isSafeInteger(entry.timestamp))return entry.timestamp;const value=entry.metadata?.timestamp??entry.value?.metadata?.timestamp??entry.timestamp;if(typeof value!=='string')return null;const parsed=Date.parse(value);return Number.isSafeInteger(parsed)?parsed:null;};
53
65
  const serverOrigin=value=>{const url=new URL(value),loopback=url.hostname==='localhost'||url.hostname==='::1'||url.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(url.hostname);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/')||(url.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return url.origin;};
54
66
  function atomic(path, value) {
@@ -67,19 +79,24 @@ function atomic(path, value) {
67
79
  }
68
80
  }
69
81
  export class TeamsConnector {
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();}
82
+ constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch,heartbeatMs=15000,nativeIdleMs=NATIVE_IDLE_MS,nativeIdleBytes=NATIVE_IDLE_BYTES}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl,heartbeatMs,nativeIdleMs,nativeIdleBytes});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.native_cache??={};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();}
71
83
  save(){atomic(this.statePath,this.state);}
72
84
  // What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
73
85
  // (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
74
86
  // so another process reads them while this one runs; the error itself stays in memory as lastError.
75
87
  get lastError(){return this.lastErrorValue;}
76
- set lastError(error){this.lastErrorValue=error;this.syncStatus().last_error=errorSummary(error,this.clock.nowMs());this.saveQuietly();}
88
+ // The same error (code and message) again changes nothing on disk, so it is not saved again: an error that repeats
89
+ // every beat does not rewrite connector.json every beat. Its time stays that of its first occurrence in the run.
90
+ set lastError(error){this.lastErrorValue=error;const status=this.syncStatus(),summary=errorSummary(error,this.clock.nowMs());if(sameError(status.last_error,summary))return;status.last_error=summary;this.saveQuietly();}
77
91
  syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
78
92
  saveQuietly(){try{this.save();}catch{}}
79
93
  noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
80
- const status=this.syncStatus(),at=this.clock.nowMs();
81
- status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...errorSummary(error,at)},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
82
- this.lastError=error;
94
+ const status=this.syncStatus(),at=this.clock.nowMs(),summary=errorSummary(error,at);
95
+ // The same session failing the same way on every beat keeps its entry where it is.
96
+ const listed=status.problems.some(item=>item.harness===harness&&item.session_id===nativeSessionId&&item.rule_id===ruleId&&sameError(item,summary));
97
+ if(!listed)status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...summary},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
98
+ this.lastErrorValue=error;const known=sameError(status.last_error,summary);if(!known)status.last_error=summary;
99
+ if(!listed||!known)this.saveQuietly();
83
100
  }
84
101
  clearSessionProblem(harness,nativeSessionId){
85
102
  const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
@@ -143,7 +160,7 @@ export class TeamsConnector {
143
160
  this.heartbeatTimer = setInterval(() => {
144
161
  if (this.abort.signal.aborted) return;
145
162
  void heartbeat().catch(error => { this.lastError = error; });
146
- }, 15000);
163
+ }, this.heartbeatMs);
147
164
  return this.configurationValue;
148
165
  }
149
166
  async effectFetch(method, path, {body, contentType='application/json', raw=false, freshKey=false}={}) {
@@ -175,7 +192,7 @@ export class TeamsConnector {
175
192
  // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
176
193
  // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
177
194
  // machine; rules over the same root and harnesses share the scan.
178
- const discoveries=new Map(),followCandidates=[];
195
+ const discoveries=new Map(),followCandidates=[],discovered=new Set();
179
196
  for(const rule of activeRules.values()){
180
197
  const root=roots.find(item=>item.id===rule.selector.root_id);
181
198
  if(!root)continue;
@@ -195,6 +212,7 @@ export class TeamsConnector {
195
212
  for(const descriptor of descriptors){
196
213
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
197
214
  if(!locator||!nativeSessionId||!harness)continue;
215
+ discovered.add(`${harness}:${nativeSessionId}`);
198
216
  const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
199
217
  // A session whose native store has not changed since this rule last captured it is not reloaded.
200
218
  // One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
@@ -213,6 +231,7 @@ export class TeamsConnector {
213
231
  }
214
232
  }
215
233
  this.nativeEffects.endDiscovery();
234
+ this.pruneNativeCache(discovered);
216
235
  this.rotateFollowers(followCandidates);
217
236
  }
218
237
  // When a session was last active: its descriptor's updated_at_ms, else its native file's modification time.
@@ -222,14 +241,16 @@ export class TeamsConnector {
222
241
  if(path)try{activity=Math.max(activity??0,statSync(path).mtimeMs);}catch{}
223
242
  return activity;
224
243
  }
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.
244
+ // Follows the most recently active candidates (active within the window): a follower is kept while it ranks
245
+ // within FOLLOW_KEEP_RANK, every other one stops, and the FOLLOW_LIMIT most recent open new follows while fewer
246
+ // than FOLLOW_LIMIT are live. A capacity refusal is not a session's failure: the connector backs off for a while.
227
247
  rotateFollowers(candidates){
228
- const now=this.clock.nowMs(),seen=new Set(),wanted=[];
248
+ const now=this.clock.nowMs(),seen=new Set(),ranked=[];
229
249
  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
250
  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;
251
+ seen.add(candidate.key);ranked.push(candidate);if(ranked.length>=FOLLOW_KEEP_RANK)break;
232
252
  }
253
+ const wanted=ranked.slice(0,FOLLOW_LIMIT);
233
254
  for(const key of this.followers.keys())if(!seen.has(key))this.stopFollower(key);
234
255
  for(const [key,until] of this.followRetry)if(until<=now)this.followRetry.delete(key);
235
256
  for(const {key,options} of wanted){
@@ -282,8 +303,77 @@ export class TeamsConnector {
282
303
  if(stamp===null||this.state.outbox.length)return true;
283
304
  const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
284
305
  const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
285
- const seen=this.state.origins[`${options.harness}:${options.nativeSessionId}`]?.capture_stamps?.[options.ruleId];
286
- return !(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode);
306
+ const key=`${options.harness}:${options.nativeSessionId}`,origin=this.state.origins[key],seen=origin?.capture_stamps?.[options.ruleId];
307
+ if(!(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode))return true;
308
+ // Published without its native bundle while it was live: captured again once it has been idle long enough.
309
+ if(origin?.native_deferred&&options.locator?.storage?.kind==='file')try{return this.nativeReady(key,statSync(realpathSync(options.locator.storage.path)));}catch{return false;}
310
+ return false;
311
+ }
312
+ // Whether a capture may attach the native bundle of a file in this state: its translation is cached (and did
313
+ // not fail), or it is small, or it has been idle for nativeIdleMs.
314
+ nativeReady(key,stat){
315
+ const cached=this.state.native_cache[key];
316
+ if(cached?.stamp===fileStamp(stat))return !cached.failed;
317
+ return stat.size<this.nativeIdleBytes||this.clock.nowMs()-stat.mtimeMs>=this.nativeIdleMs;
318
+ }
319
+ // Native artifact bytes are spilled to content-addressed files under the state directory, never into
320
+ // connector.json: a 129 MB transcript was ~172 MB of base64 held in the outbox and rewritten on every save().
321
+ spillDir(){return join(this.stateDir,'artifacts');}
322
+ spill(bytes){
323
+ const sha256=hash(bytes),name=`${sha256}.bin`,path=join(this.spillDir(),name);
324
+ if(!existsSync(path)){
325
+ mkdirSync(this.spillDir(),{recursive:true,mode:0o700});
326
+ const temporary=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(temporary,'wx',0o600);
327
+ try{writeFileSync(fd,bytes);fsyncSync(fd);}catch(error){closeSync(fd);try{unlinkSync(temporary);}catch{}throw error;}
328
+ closeSync(fd);renameSync(temporary,path);
329
+ }
330
+ const chunk_sha256=[];for(let at=0;at<bytes.length||chunk_sha256.length===0;at+=CHUNK_BYTES)chunk_sha256.push(hash(bytes.subarray(at,Math.min(bytes.length,at+CHUNK_BYTES))));
331
+ return {spill:name,sha256,byte_length:bytes.length,chunk_sha256};
332
+ }
333
+ openSpill(name){return openSync(join(this.spillDir(),name),'r');}
334
+ // Each chunk of a spilled artifact, read back one at a time; null when the file is gone or no longer matches.
335
+ *spilledChunks(artifact){
336
+ let fd;try{fd=this.openSpill(artifact.spill.file);}catch{yield null;return;}
337
+ try{
338
+ for(let index=0;index<artifact.manifest.chunks;index++){
339
+ const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
340
+ const read=length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0;
341
+ if(read!==length||hash(bytes)!==artifact.spill.chunk_sha256[index]){yield null;return;}
342
+ yield {upload_id:artifact.manifest.upload_id,index,sha256:artifact.spill.chunk_sha256[index],bytes_base64:bytes.toString('base64')};
343
+ }
344
+ }finally{closeSync(fd);}
345
+ }
346
+ // Spill files no queued capture and no cached translation refers to are removed.
347
+ pruneSpill(){
348
+ const kept=new Set();
349
+ for(const item of this.state.outbox)for(const artifact of item.artifacts??[])if(artifact.spill)kept.add(artifact.spill.file);
350
+ for(const cached of Object.values(this.state.native_cache))for(const file of cached.files??[])kept.add(file.spill);
351
+ let names=[];try{names=readdirSync(this.spillDir());}catch{return;}
352
+ for(const name of names)if(!kept.has(name)&&!name.endsWith('.tmp'))try{unlinkSync(join(this.spillDir(),name));}catch{}
353
+ }
354
+ // A cached translation of a session no active rule discovers any more (deleted, moved, its rule gone) is dropped
355
+ // with its spill files, so a deleted 129 MB transcript's bundle does not stay on disk for good.
356
+ pruneNativeCache(discovered){
357
+ // A beat that discovers nothing while translations are cached (a home on a drive that is briefly offline) says
358
+ // nothing about those sessions, so it keeps them rather than re-translating every large session later.
359
+ if(!discovered.size&&Object.keys(this.state.native_cache).length)return;
360
+ const stale=Object.keys(this.state.native_cache).filter(key=>!discovered.has(key));if(!stale.length)return;
361
+ for(const key of stale)delete this.state.native_cache[key];
362
+ this.save();this.pruneSpill();
363
+ }
364
+ // A translated bundle, spilled, in the form the cache keeps: one entry per file, primary first or not.
365
+ spillTranslated(translated,defaultName){
366
+ const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
367
+ if(files.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
368
+ let total=0;const spilled=[];
369
+ for(const file of files){const bytes=Buffer.from(file.content,'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});spilled.push({path:file.path,role:file.role,...this.spill(bytes)});}
370
+ return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
371
+ }
372
+ // The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
373
+ bundleArtifacts(bundle,harness){
374
+ const artifacts=[],files=[];
375
+ for(const file of bundle.files){const uploadId=`upload_${randomBytes(16).toString('hex')}`;artifacts.push({manifest:{upload_id:uploadId,relative_name:file.path,sha256:file.sha256,byte_length:file.byte_length,chunks:file.chunk_sha256.length,media_type:'application/octet-stream',role:file.role==='source_recovery'?'sidecar':'native'},spill:{file:file.spill,chunk_sha256:file.chunk_sha256}});files.push({upload_id:uploadId,role:file.role});}
376
+ return {artifacts,nativeBundle:{id:files.find(file=>file.role==='primary').upload_id,harness,fidelity:bundle.fidelity,residue:bundle.residue,files}};
287
377
  }
288
378
  recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
289
379
  const origin=this.state.origins[key];
@@ -305,7 +395,11 @@ export class TeamsConnector {
305
395
  const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
306
396
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
307
397
  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};});
308
- if(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])){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
398
+ // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
399
+ const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function';
400
+ const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
401
+ 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]);
402
+ if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
309
403
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
310
404
  if(!previous&&backfillPolicy.mode==='none'){const toolTail=trailingToolResults(messages);this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),...(toolTail?{baseline_tool_tail:toolTail}:{}),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
311
405
  if(!previous&&backfillPolicy.mode==='since'){
@@ -330,7 +424,9 @@ export class TeamsConnector {
330
424
  if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
331
425
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
332
426
  effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
333
- this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};
427
+ // A tool tail recorded for the old baseline describes positions that no longer hold; it goes with it.
428
+ delete effectivePrevious.baseline_tool_tail;
429
+ this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};delete this.state.origins[key].baseline_tool_tail;
334
430
  }else if(baselineMoved)throw Object.assign(new Error(`the history kept back when rule ${ruleId} started was rewritten; run \`supercode teams sync resume ${ruleId} --backfill all\` (or a since window) to republish this session`),{code:'resync_required'});
335
431
  }
336
432
  if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
@@ -338,29 +434,55 @@ export class TeamsConnector {
338
434
  const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
339
435
  const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
340
436
  const parts=[];for(let index=0;index<messages.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:messages.slice(index,index+500)});
341
- const artifacts=[];
437
+ let artifacts=[];
342
438
  // Native stores normally live in harness homes outside the repository. The locator is
343
439
  // accepted only when it came from this configured core discovery, and raw bytes are safe
344
440
  // to publish only when the complete native history is in the permitted projection.
345
- let nativeBundle=null;
441
+ let nativeBundle=null,nativeDeferred=false;
346
442
  // A session in a shared SQLite store (OpenCode, Goose) has no file of its own to stat; it is
347
443
  // stable when the core reads back the same messages this capture publishes.
348
444
  const sqliteSource=locator?.storage?.kind==='sqlite';
349
- if((artifactSource||sqliteSource)&&backfillPolicy.mode==='all'&&!effectivePrevious?.baseline&&typeof this.core.translateSession==='function'){
350
- if(artifactSource&&!artifactBefore.isFile())throw Object.assign(new Error('native artifact locator is not a regular file'),{code:'invalid_request'});
351
- const translated=(await this.core.translateSession({locator,target_harness:harness})).artifact;
352
- const changed=new Error('native session changed while it was being captured');
353
- if(artifactSource){const after=statSync(artifactSource);if(artifactBefore.dev!==after.dev||artifactBefore.ino!==after.ino||artifactBefore.size!==after.size||artifactBefore.mtimeMs!==after.mtimeMs)throw Object.assign(changed,{code:'resync_required'});}
354
- else{const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(changed,{code:'resync_required'});}
355
- const translatedFiles=translated.files?.length?translated.files:[{path:translated.suggested_filename??(artifactSource?basename(artifactSource):`${nativeSessionId}.json`),content:translated.content,role:'primary'}];
356
- if(translatedFiles.filter(file=>file.role==='primary').length!==1)throw Object.assign(new Error('native codec bundle must contain exactly one primary file'),{code:'fidelity_unavailable'});
357
- let total=0;const bundleFiles=[];
358
- for(const file of translatedFiles){const bytes=Buffer.from(file.content,'utf8');total+=bytes.length;if(total>256*1024*1024)throw Object.assign(new Error('native artifact bundle exceeds 256 MiB'),{code:'request_too_large'});const uploadId=`upload_${randomBytes(16).toString('hex')}`,chunks=[];for(let at=0,index=0;at<bytes.length||index===0;at+=1024*1024,index++){const chunk=bytes.subarray(at,Math.min(bytes.length,at+1024*1024));chunks.push({upload_id:uploadId,index,sha256:hash(chunk),bytes_base64:chunk.toString('base64')});if(bytes.length===0)break;}artifacts.push({manifest:{upload_id:uploadId,relative_name:file.path,sha256:hash(bytes),byte_length:bytes.length,chunks:chunks.length,media_type:'application/octet-stream',role:file.role==='source_recovery'?'sidecar':'native'},chunks});bundleFiles.push({upload_id:uploadId,role:file.role});}
359
- nativeBundle={id:bundleFiles.find(file=>file.role==='primary').upload_id,harness,fidelity:translated.fidelity,residue:translated.residue??[],files:bundleFiles};
445
+ // Translating reads the whole native file, so its time grows with the file: a 129 MB Claude Code transcript
446
+ // outlasted the client's 30 s default on every beat and never synced. About 2 s per MB, from 30 s to 10 min.
447
+ const translateTimeoutMs=Math.min(600000,Math.max(30000,Math.ceil((artifactBefore?.size??0)/1e6)*2000));
448
+ if(artifactSource&&nativeWanted){
449
+ if(!artifactBefore.isFile())throw Object.assign(new Error('native artifact locator is not a regular file'),{code:'invalid_request'});
450
+ // A file source's bundle is best effort: when it cannot be attached (the file is live, it moved while it was
451
+ // read, the rule's kept-back baseline is still being cleared, or translating it failed) the messages are
452
+ // published without it and nativeDeferred brings the session back once the file is idle. Coverage speaks
453
+ // of the message history, which is whole either way, so it is unchanged; the capture simply names no
454
+ // artifacts, and the server refuses a native export or continue of that revision (fidelity_unavailable).
455
+ const onDisk=fileStamp(artifactBefore);let bundle=null;
456
+ if(!effectivePrevious?.baseline&&this.nativeReady(key,artifactBefore)){
457
+ const cached=this.state.native_cache[key];
458
+ if(cached?.stamp===onDisk&&cached.files.every(file=>existsSync(join(this.spillDir(),file.spill))))bundle=cached;
459
+ else try{
460
+ const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
461
+ if(fileStamp(statSync(artifactSource))===onDisk){
462
+ bundle={stamp:onDisk,...this.spillTranslated(translated,basename(artifactSource))};
463
+ // Only a translation that took real time is worth keeping; a small file translates again in no time.
464
+ if(artifactBefore.size>=this.nativeIdleBytes){this.state.native_cache[key]=bundle;this.save();this.pruneSpill();}
465
+ }
466
+ }catch(error){
467
+ if(this.abort.signal.aborted)throw error;
468
+ this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
469
+ // The same file is not translated again; a write to it clears this.
470
+ this.state.native_cache[key]={stamp:onDisk,failed:true,files:[]};this.save();this.pruneSpill();
471
+ }
472
+ }
473
+ // The messages were loaded after artifactBefore was read, so they match the bundle only if the file is still that file.
474
+ if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
475
+ if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
476
+ // A retry that still has no bundle would republish the same messages for nothing.
477
+ if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
478
+ }else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
479
+ const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
480
+ const digest=entries=>hash(canonicalJson((entries.messages??entries.turns??[]).map(entry=>entry.value??entry)));if(digest(await this.core.session(locator).load())!==digest(loaded))throw Object.assign(new Error('native session changed while it was being captured'),{code:'resync_required'});
481
+ ({artifacts,nativeBundle}=this.bundleArtifacts(this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
360
482
  }
361
483
  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}:{})};
362
484
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
363
- this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,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();
485
+ 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();
364
486
  }
365
487
  flush() {
366
488
  const work=this.work.then(()=>this.flushSerial());
@@ -369,7 +491,49 @@ export class TeamsConnector {
369
491
  }
370
492
  // Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
371
493
  async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
372
- async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
494
+ // A spill file is hashed once per version on disk (its stat stamp) and only stat'ed after that. It is lost only
495
+ // when it is missing, the wrong size or damaged; any other failure to read it (a Windows antivirus lock) is thrown,
496
+ // which keeps the capture queued with its bundle for the next flush.
497
+ // A read error that never clears (a lock never released, a lasting EACCES) is retried on five flushes, then the
498
+ // file counts as lost: the outbox is flushed in order, so one unreadable file would otherwise stop every session.
499
+ spillLost(artifact){
500
+ const failures=this.spillReadFailures??=new Map();
501
+ try{const lost=this.spillLostOnce(artifact);failures.delete(artifact.spill.file);return lost;}
502
+ catch(error){
503
+ const count=(failures.get(artifact.spill.file)??0)+1;failures.set(artifact.spill.file,count);
504
+ if(count<5)throw error;
505
+ failures.delete(artifact.spill.file);
506
+ process.stderr.write(`supercode teams: a spilled native artifact could not be read on ${count} flushes (${error.message}); treating it as lost\n`);
507
+ return true;
508
+ }
509
+ }
510
+ spillLostOnce(artifact){
511
+ const path=join(this.spillDir(),artifact.spill.file),missing=error=>error?.code==='ENOENT'||error?.code==='ENOTDIR';
512
+ let stat;try{stat=statSync(path);}catch(error){if(missing(error))return true;throw error;}
513
+ if(!stat.isFile()||stat.size!==artifact.manifest.byte_length)return true;
514
+ const stamp=fileStamp(stat),verified=this.verifiedSpills??=new Map();
515
+ if(verified.get(artifact.spill.file)===stamp)return false;
516
+ let fd;try{fd=this.openSpill(artifact.spill.file);}catch(error){if(missing(error))return true;throw error;}
517
+ try{
518
+ for(let index=0;index<artifact.manifest.chunks;index++){
519
+ const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
520
+ if((length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0)!==length||hash(bytes)!==artifact.spill.chunk_sha256[index])return true;
521
+ }
522
+ }finally{closeSync(fd);}
523
+ verified.set(artifact.spill.file,stamp);return false;
524
+ }
525
+ // A queued capture whose spilled artifact is gone or damaged on disk would never flush: it is sent without
526
+ // its native bundle instead (its digest resealed), and the session is captured again for one.
527
+ dropLostArtifacts(item){
528
+ if(!(item.artifacts??[]).some(artifact=>artifact.spill&&this.spillLost(artifact)))return;
529
+ process.stderr.write(`supercode teams: a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} lost its native artifact on disk; sending it without one\n`);
530
+ item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
531
+ const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
532
+ }
533
+ async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
534
+ // A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
535
+ for(const artifact of item.artifacts??[])for(const chunk of artifact.spill?this.spilledChunks(artifact):artifact.chunks){if(!chunk)throw Object.assign(new Error('a spilled native artifact changed on disk while it was sent'),{code:'resync_required'});await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);}
536
+ return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
373
537
  // True when the server refused a queued capture because its sync rule was removed, paused or revised since it
374
538
  // was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
375
539
  // refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
@@ -381,6 +545,6 @@ export class TeamsConnector {
381
545
  if(!Array.isArray(configuration?.rules))return false;
382
546
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
383
547
  }
384
- async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;try{receipt=await this.sendCapture(item);}catch(error){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.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();latest=receipt;}return latest;}
548
+ async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);try{receipt=await this.sendCapture(item);}catch(error){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.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();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
385
549
  stop(){if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
386
550
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
2
+ import { join, resolve, sep } from 'node:path';
3
3
 
4
4
  // A continuation materializes a new native session (new ids), so value_lossless is the only
5
5
  // fidelity it delivers.
@@ -19,7 +19,7 @@ export function createNativeContinuationAdapter({ core }) {
19
19
  if (signal?.aborted) throw signal.reason ?? new Error('native continuation was aborted');
20
20
  const files = ticket.artifact.files.map(file => {
21
21
  const path = resolve(directory, file.relative_name);
22
- if (!path.startsWith(`${resolve(directory)}/`)) throw unsupported('native continuation artifact escapes its verified directory');
22
+ if (!path.startsWith(`${resolve(directory)}${sep}`)) throw unsupported('native continuation artifact escapes its verified directory');
23
23
  return { path: file.relative_name, content: readFileSync(path, 'utf8'), role: file.role };
24
24
  });
25
25
  const primary = files.filter(file => file.role === 'primary');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
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",
@@ -61,7 +61,7 @@
61
61
  "@volter/supercode-frontend": "^0.2.2",
62
62
  "@volter/supercode-harness-sdk": "^0.3.43",
63
63
  "@volter/supercode-terminal": "^0.2.30",
64
- "@volter/identity": "^0.4.0",
64
+ "@volter/identity": "^0.4.1",
65
65
  "@volter/teams": "^0.1.8",
66
66
  "@xterm/addon-serialize": "^0.14.0",
67
67
  "@xterm/headless": "^6.0.0",
@@ -63,7 +63,7 @@ function streamQueue(stream, work) {
63
63
 
64
64
  const sessionArgs = (api, auth, ref) => ({
65
65
  teamId: auth.team_id, principalId: auth.principal_id,
66
- credentialId: auth.credential_id, ref, now: api.clock.nowMs(),
66
+ credentialId: auth.credential_id, ref, now: api.clock.nowMs(), bound: auth.bound,
67
67
  });
68
68
 
69
69
  async function readForEvent(api, auth, ref, tail) {
@@ -12,6 +12,7 @@ import { normalizeSearch, textFromContent, cursorHash, cursorToken } from './cat
12
12
  import { captureDigest, captureMessages, handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
13
13
  import { handleCatalogEvents, handleSessionEvents } from './event-routes.mjs';
14
14
  import { exact } from '@volter/teams/server/request-json';
15
+ import { delegatedSessionOf, looksDelegated } from '@volter/teams/server/delegated';
15
16
  import { renderSourceCollectorConfig } from './otlp/collector-config.mjs';
16
17
  import { attachmentActionRoute, createAttachment, liveGrantRoute, machineEffectRoute } from './effects.mjs';
17
18
  import { exportSessionRoute, operationRoute } from './exports.mjs';
@@ -21,6 +22,20 @@ import { fail, parseJson, result, send, guardedMutation, requireRevision, checkR
21
22
 
22
23
  async function preAuth({ api, req, res, path, requestId }) {
23
24
  let early;
25
+ // A session view (RH2 ADR 0008): a workplace page reads one session with a token its workplace signed for a Room and
26
+ // Conversation, which reaches only a session its owner bound to them (session_view_bindings). A member's own
27
+ // credential takes the ordinary routes below.
28
+ if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)(\/messages|\/events)?$/.exec(path))&&looksDelegated(bearer(req.headers.authorization))){
29
+ const access=await delegatedSessionOf(api,bearer(req.headers.authorization));
30
+ const ref=decodeURIComponent(early[2]);
31
+ if(!access||access.teamId!==early[1]||access.sessionRef!==ref)fail(401,'authentication_required','authentication required');
32
+ const auth={kind:'session_view',team_id:early[1],principal_id:null,credential_id:null,bound:{issuer:access.issuer,room:access.room,conversation:access.conversation,expiresAt:access.expiresAt}};
33
+ const url=new URL(req.url,'http://local');
34
+ if(!early[3])await sessionDescriptor(api,res,auth,ref,requestId);
35
+ else if(early[3]==='/messages')await readSession(api,res,auth,ref,url,requestId);
36
+ else await handleSessionEvents({api,req,res,auth,id:ref,url,requestId,fail});
37
+ return true;
38
+ }
24
39
  if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/attachments\/([^/]+)\/stream$/.exec(path))){const token=bearer(req.headers.authorization);checkRate(api,`attachment:${hashToken(token??'')}`,120,30);await attachmentStreamRoute({api,req,res,teamId:early[1],attachmentId:early[2],token,fail});return true;}
25
40
  return false;
26
41
  }
@@ -52,6 +67,7 @@ async function team({ api, req, res, auth, url, path, sub, requestId }) {
52
67
  if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/exports$/.exec(sub))){await exportSessionRoute({api,req,res,auth,sessionId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
53
68
  if(req.method==='POST'&&(m=/^\/sessions\/([^/]+)\/continuations$/.exec(sub))){await continuationRoute({api,req,res,auth,sessionId:m[1],requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
54
69
  if(req.method==='GET'&&(m=/^\/sessions\/([^/]+)\/events$/.exec(sub))){await handleSessionEvents({api,req,res,auth,id:m[1],url,requestId,resolve,canReadMerged,readerProjection,fail});return true;}
70
+ if(['GET','POST','DELETE'].includes(req.method)&&(m=/^\/sessions\/([^/]+)\/view-bindings$/.exec(sub))){await viewBindingRoute(api,req,res,auth,m[1],requestId);return true;}
55
71
  if((m=/^\/sessions\/([^/]+)\/access(?:\/([^/]+))?$/.exec(sub))){await accessRoute(api,req,res,auth,m[1],m[2],requestId);return true;}
56
72
  if((m=/^\/machines\/([^/]+)\/grants(?:\/([^/]+))?$/.exec(sub))){await liveGrantRoute({api,req,res,auth,machineId:m[1],grantId:m[2],url,requestId,parseJson,exact,fail,send,result,guardedMutation,requireRevision});return true;}
57
73
  if((m=/^\/machines\/([^/]+)\/effects(?:\/([^/]+)(?:\/(claim|result))?)?$/.exec(sub))){await machineEffectRoute({api,req,res,auth,machineId:m[1],effectId:m[2],action:m[3],url,requestId,parseJson,exact,fail,send,result,guardedMutation});return true;}
@@ -146,7 +162,7 @@ async function listSessions(api,res,auth,url,requestId,search){
146
162
  send(res,200,body,requestId);
147
163
  }
148
164
  async function sessionDescriptor(api,res,auth,id,requestId){
149
- const row=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs()});
165
+ 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});
150
166
  send(res,200,result(api,auth,JSON.parse(row.descriptor_json)),requestId,{ETag:`"${row.revision}"`});
151
167
  }
152
168
  async function correlateRoute(api,req,res,auth,path,requestId){
@@ -168,7 +184,7 @@ async function readSession(api,res,auth,id,url,requestId){
168
184
  const tail=query.tail??100;
169
185
  if(query.before&&query.revision!==undefined)fail(400,'invalid_request','revision cannot be combined with before');
170
186
  const nextCursor=cursorToken();
171
- const snapshot=await api.store.readSessionSnapshot({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),beforeHash:query.before?cursorHash(query.before):null,requestedRevision:query.revision,tail,nextCursorHash:cursorHash(nextCursor),nextCursorToken:nextCursor});
187
+ const snapshot=await api.store.readSessionSnapshot({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:id,now:api.clock.nowMs(),bound:auth.bound,beforeHash:query.before?cursorHash(query.before):null,requestedRevision:query.revision,tail,nextCursorHash:cursorHash(nextCursor),nextCursorToken:nextCursor});
172
188
  const {rows,beforeCursor,revision}=snapshot;
173
189
  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)}));
174
190
  send(res,200,{version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},descriptor:JSON.parse(snapshot.descriptor_json),messages,before_cursor:beforeCursor,event_cursor:snapshot.eventCursor,revision,display:{returned:messages.length,total:snapshot.total,truncated_fields:0}},requestId);
@@ -246,3 +262,23 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
246
262
  statements.push({sql:'INSERT INTO session_versions(team_id,session_id,revision,descriptor_json,source_projection_json,search_title,sort_at,created_at) VALUES (?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,{$journalJsonValue:descriptor},JSON.stringify({source_id:sourceId}),normalizeSearch(commit.title??''),sortAt,boundaryNow]});let ordinal=0;for(const m of messages){let mid=(await api.store.one('SELECT id FROM message_ids WHERE team_id=? AND source_id=? AND harness=? AND native_id=? AND native_key=?',[auth.team_id,sourceId,cap.harness,cap.native_id,m.native_key]))?.id;if(!mid){mid=api.ids.next('message');statements.push({sql:'INSERT INTO message_ids(team_id,source_id,harness,native_id,native_key,id) VALUES (?,?,?,?,?,?)',params:[auth.team_id,sourceId,cap.harness,cap.native_id,m.native_key,mid]});}statements.push({sql:'INSERT INTO messages(team_id,session_id,revision,ordinal,id,source_id,native_key,recorded_at,value_json,search_text) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,ordinal++,mid,sourceId,m.native_key,m.recorded_at,JSON.stringify(m.value),normalizeSearch(textFromContent(m.value.content))]});}
247
263
  const bundle=commit.native_bundle??null,legacyBundle=commit.artifacts.length?captureId:null;for(let index=0;index<commit.artifacts.length;index++){const a=commit.artifacts[index],file=bundle?.files.find(x=>x.upload_id===a.upload_id);statements.push({sql:'UPDATE uploads SET manifest_json=?,committed=1 WHERE team_id=? AND id=?',params:[JSON.stringify(a),auth.team_id,a.upload_id]},{sql:'INSERT INTO session_artifacts(team_id,session_id,revision,bundle_id,upload_id,format,provenance,file_role,fidelity,residue_json) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,bundle?.id??legacyBundle,a.upload_id,bundle?.harness??cap.harness,'capture',file?.role??(index===0?'primary':'bundle'),bundle?.fidelity??'semantic',JSON.stringify(bundle?.residue??['native_bundle_metadata_unavailable'])]});}const storedReceipt={source_id:sourceId,capture_id:captureId,session_id:sid,generation:cap.generation,sequence:cap.sequence,digest:commit.digest,revision:'__JOURNAL_REVISION__',duplicate:false};statements.push({sql:"UPDATE captures SET state='committed',commit_json=?,receipt_json=? WHERE team_id=? AND id=?",params:[JSON.stringify(commit),{$journalJsonValue:storedReceipt},auth.team_id,captureId]},{sql:'UPDATE sources SET last_received_at=? WHERE team_id=? AND id=?',params:[boundaryNow,auth.team_id,sourceId]},{sql:'UPDATE teams SET journal_revision=? WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,sid,'capture',boundaryNow]});const guards=[{sql:'SELECT 1 ok FROM credentials WHERE id=? AND source_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,sourceId,boundaryNow],code:'authentication_required',status:401,message:'source credential expired or revoked'},...lineageGuards,{sql:"SELECT 1 ok FROM captures c JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id LEFT JOIN machines m ON m.team_id=s.team_id AND m.id=s.machine_id LEFT JOIN sync_rules r ON r.team_id=c.team_id AND r.id=json_extract(c.begin_json,'$.rule_id') WHERE c.team_id=? AND c.id=? AND c.source_id=? AND c.state='staging' AND s.state='active' AND (s.kind!='connector' OR (m.revoked_at IS NULL AND r.machine_id=m.id AND r.source_id=s.id AND r.state='active' AND r.revision=json_extract(c.begin_json,'$.rule_revision') AND r.visibility=json_extract(c.begin_json,'$.visibility')))",params:[auth.team_id,captureId,sourceId],code:'conflict',status:409,message:'capture authorization changed'}],storedResponse=result(api,auth,storedReceipt),{revision}=await guardedMutation(api,res,auth,guards,statements,{status:200,body:storedResponse,targetId:captureId,journal:true}),receipt={...storedReceipt,revision};api.events.publish(auth.team_id,{type:'session_changed',cursor:String(revision),revision,session_id:sid,reason:'capture',read_required:true});send(res,200,result(api,auth,receipt),requestId);
248
264
  }
265
+
266
+ /**
267
+ * A session's views (RH2 ADR 0008): its owner lets one workplace Room's Conversation read it, and that workplace's
268
+ * signed tokens then reach this session and nothing else (preAuth). GET lists them; POST adds one; DELETE removes one.
269
+ */
270
+ async function viewBindingRoute(api,req,res,auth,ref,requestId){
271
+ if(!['user','service'].includes(auth.kind))fail(403,'forbidden','credential cannot bind sessions');
272
+ const session=await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref,now:api.clock.nowMs()});
273
+ 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]);
274
+ if(req.method==='GET'){send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings:await list()}),requestId);return;}
275
+ if(session.custodian_id!==auth.principal_id)fail(403,'forbidden',"only the session's owner binds it to a view");
276
+ const body=await parseJson(req);exact(body,['issuer','room','conversation']);
277
+ if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
278
+ if(!api.delegatedIssuer||body.issuer!==api.delegatedIssuer)fail(400,'invalid_request',`this server accepts views signed by ${api.delegatedIssuer??'no workplace'}`);
279
+ const key=[auth.team_id,session.id,body.issuer,body.room,body.conversation];
280
+ await api.store.transaction(req.method==='POST'
281
+ ?[{sql:'INSERT OR IGNORE INTO session_view_bindings(team_id,session_id,issuer,room,conversation,bound_by,created_at) VALUES (?,?,?,?,?,?,?)',params:[...key,auth.principal_id,api.clock.nowMs()]}]
282
+ :[{sql:'DELETE FROM session_view_bindings WHERE team_id=? AND session_id=? AND issuer=? AND room=? AND conversation=?',params:key}]);
283
+ send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings:await list()}),requestId);
284
+ }
@@ -46,6 +46,10 @@ class Reader {
46
46
  const signed64 = (value) => BigInt.asIntN(64, value);
47
47
  const zigzag64 = (value) => (value >> 1n) ^ -(value & 1n);
48
48
 
49
+ // As `json: true`: ProtoJSON spells non-finite floats and doubles "NaN", "Infinity" and "-Infinity"
50
+ // (JSON has no such numbers; JSON.stringify would turn them into null).
51
+ const protoJsonDouble = (value) => Number.isFinite(value) ? value : String(value);
52
+
49
53
  function scalar(type, wire, reader) {
50
54
  switch (type) {
51
55
  case 'string': return utf8.decode(reader.take(Number(reader.varint())));
@@ -59,10 +63,10 @@ function scalar(type, wire, reader) {
59
63
  case 'sint64': return zigzag64(reader.varint()).toString();
60
64
  case 'fixed32': return reader.fixed32();
61
65
  case 'sfixed32': return reader.fixed32() | 0;
62
- case 'float': { const at = reader.pos; reader.take(4); return reader.view.getFloat32(at, true); }
66
+ case 'float': { const at = reader.pos; reader.take(4); return protoJsonDouble(reader.view.getFloat32(at, true)); }
63
67
  case 'fixed64': return reader.fixed64().toString();
64
68
  case 'sfixed64': return signed64(reader.fixed64()).toString();
65
- case 'double': { const at = reader.pos; reader.take(8); return reader.view.getFloat64(at, true); }
69
+ case 'double': { const at = reader.pos; reader.take(8); return protoJsonDouble(reader.view.getFloat64(at, true)); }
66
70
  default: throw new TypeError(`unsupported protobuf scalar ${type} (wire ${wire})`);
67
71
  }
68
72
  }
@@ -22,20 +22,30 @@ export function createStoreHandlers({ all, get, run, tx }) {
22
22
  const journalParam=(value,revision)=>value&&typeof value==='object'&&value.$journalRevision===true?revision:value&&typeof value==='object'&&Object.hasOwn(value,'$journalJson')?JSON.stringify({...value.$journalJson,revision}):value&&typeof value==='object'&&Object.hasOwn(value,'$journalJsonValue')?JSON.stringify(journalValue(value.$journalJsonValue,revision)):value;
23
23
  const domainError=(status,code,message)=>{const error=new Error(message);error.status=status;error.code=code;throw error;};
24
24
  const authorizedSession=(args)=>{
25
- const {teamId,principalId,credentialId,ref,now}=args;
26
- const credential=get("SELECT id FROM credentials WHERE id=? AND principal_id=? AND kind IN ('user','service') AND (team_id IS NULL OR team_id=?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)",[credentialId,principalId,teamId,now]);
27
- const membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
28
- if(!credential||!membership)domainError(401,'authentication_required','authentication required');
25
+ const {teamId,principalId,credentialId,ref,now,bound}=args;
26
+ // `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
27
+ // session its owner bound to that Room and Conversation; it holds no credential and is no member.
28
+ let membership=null;
29
+ if(bound){
30
+ if(!(bound.expiresAt>now))domainError(401,'authentication_required','authentication required');
31
+ }else{
32
+ const credential=get("SELECT id FROM credentials WHERE id=? AND principal_id=? AND kind IN ('user','service') AND (team_id IS NULL OR team_id=?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)",[credentialId,principalId,teamId,now]);
33
+ membership=get('SELECT role FROM memberships WHERE team_id=? AND principal_id=? AND active=1',[teamId,principalId]);
34
+ if(!credential||!membership)domainError(401,'authentication_required','authentication required');
35
+ }
29
36
  if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
30
37
  const groups=all('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,principalId]).map(row=>row.group_id);
31
- const candidates=all('SELECT * FROM sessions WHERE team_id=? AND id LIKE ? AND deleted_at IS NULL',[teamId,`${ref}%`]);
38
+ // A session is named by its id (or a prefix of it) or by the harness's own id for it (a native
39
+ // session id such as a Claude Code session's uuid), which its origin records.
40
+ let candidates=all('SELECT * FROM sessions WHERE team_id=? AND id LIKE ? AND deleted_at IS NULL',[teamId,`${ref}%`]);
41
+ if(!candidates.length)candidates=all('SELECT DISTINCT s.* FROM sessions s JOIN origins o ON o.team_id=s.team_id AND o.session_id=s.id WHERE s.team_id=? AND o.native_id=? AND o.deleted_at IS NULL AND s.deleted_at IS NULL',[teamId,ref]);
32
42
  const visible=new Map();
33
43
  for(const candidate of candidates){
34
44
  let root=candidate;const seen=new Set();
35
45
  while(root.canonical_id){if(seen.has(root.id))domainError(409,'conflict','correlation cycle');seen.add(root.id);root=get('SELECT * FROM sessions WHERE team_id=? AND id=? AND deleted_at IS NULL',[teamId,root.canonical_id]);if(!root)break;}
36
46
  if(!root)continue;
37
47
  const family=all('WITH RECURSIVE family(id) AS (SELECT ? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=?) SELECT s.* FROM sessions s JOIN family f ON f.id=s.id WHERE s.team_id=?',[root.id,teamId,teamId]);
38
- const readable=family.every(session=>{
48
+ 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=>{
39
49
  if(session.deleted_at!=null)return false;
40
50
  if(session.visibility==='team'||session.custodian_id===principalId)return true;
41
51
  const grants=all('SELECT subject_kind,subject_id,expires_at FROM session_grants WHERE team_id=? AND session_id=?',[teamId,session.id]);
@@ -57,8 +67,10 @@ export function createStoreHandlers({ all, get, run, tx }) {
57
67
  return {session:{...session,descriptor_json:JSON.stringify(projection)},membership};
58
68
  };
59
69
  const readSessionSnapshot=args=>tx(()=>{
60
- const {teamId,principalId,credentialId,ref,now,beforeHash,requestedRevision,tail,nextCursorHash,nextCursorToken}=args;
61
- const {session}=authorizedSession(args);const team=get('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[teamId]);
70
+ const {teamId,ref,now,beforeHash,requestedRevision,tail,nextCursorHash,nextCursorToken}=args;
71
+ const {session}=authorizedSession(args);
72
+ // A bound view's history cursors are kept under the session's owner, the member it reads for.
73
+ const principalId=args.bound?session.custodian_id:args.principalId;const team=get('SELECT auth_epoch,journal_revision FROM teams WHERE id=?',[teamId]);
62
74
  let revision,ordinals;
63
75
  if(beforeHash){
64
76
  const cursor=get("SELECT * FROM cursors WHERE id=? AND team_id=? AND principal_id=? AND kind='history'",[beforeHash,teamId,principalId]);