@volter/supercode-teams 0.2.148 → 0.2.150

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.
@@ -408,6 +408,7 @@ export class TeamsConnector {
408
408
  // Whether a capture may attach the native bundle of a file in this state: its translation is cached (and did
409
409
  // not fail), or it has been idle for nativeIdleMs.
410
410
  nativeReady(key,stat){
411
+ if ((this.state.native_upload_deferred_until ?? 0) > this.clock.nowMs()) return false;
411
412
  const cached=this.state.native_cache[key];
412
413
  if(cached?.stamp===fileStamp(stat))return !cached.failed;
413
414
  return this.clock.nowMs()-stat.mtimeMs>=this.nativeIdleMs;
@@ -739,6 +740,17 @@ export class TeamsConnector {
739
740
  item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
740
741
  const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
741
742
  }
743
+ deferNativeUnderPressure(item) {
744
+ if (!item.artifacts?.length) return false;
745
+ // A native bundle waiting for disk headroom must not hold every message
746
+ // behind it in the shared outbox. Commit the messages and retry the native
747
+ // bundle after pressure clears, using the same deferred-capture mechanism.
748
+ item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
749
+ const commit={...item.commit};delete commit.digest;
750
+ item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit}));
751
+ this.state.native_upload_deferred_until=this.clock.nowMs()+60000;
752
+ this.save();return true;
753
+ }
742
754
  // A capture queued before parts were bounded by bytes can hold a part the server refuses as too large, and the
743
755
  // outbox is flushed in order: it is split again, under a new capture id (the server drops the old reservation of
744
756
  // the same revision) and its digest resealed, so it neither fails nor holds every later capture behind it.
@@ -787,7 +799,7 @@ export class TeamsConnector {
787
799
  if(!Array.isArray(configuration?.rules))return false;
788
800
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
789
801
  }
790
- 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.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.
802
+ 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.
791
803
  this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
792
804
  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();}
793
805
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.148",
3
+ "version": "0.2.150",
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",
@@ -307,8 +307,8 @@ async function artifactChunk(api,req,res,auth,sourceId,uploadId,index,requestId)
307
307
  const blob = await api.store.one('SELECT 1 ok FROM artifact_blobs WHERE team_id=? AND sha256=?',[auth.team_id,b.sha256]);
308
308
  // Keep allocation headroom for heartbeats, messages and reclamation. Replays
309
309
  // above remain readable; a refused chunk stays in the connector's outbox.
310
- const usage = api.storageUsage?.();
311
- if (usage && usage.used + (blob ? 0 : bytes.length) >= usage.limit * 0.9) {
310
+ const usage = await api.storageUsage?.();
311
+ if (usage && (usage.used + (blob ? 0 : bytes.length) >= usage.limit * 0.9 || (!blob && usage.available_bytes != null && usage.available_bytes - bytes.length < (usage.min_free_bytes ?? 0)))) {
312
312
  res.setHeader('Retry-After', '60');
313
313
  fail(503, 'storage_pressure', 'native upload deferred while Teams storage is above its headroom threshold');
314
314
  }
package/server/index.mjs CHANGED
@@ -25,11 +25,11 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
25
25
  // The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
26
26
  api.attach({harnessRoutes});
27
27
  const events=api.events;
28
- let store,http,upgrade,lockFd,maintenanceTimer,started=false;
28
+ let store,http,upgrade,lockFd,maintenanceTimer,maintenanceWork,started=false;
29
29
  api.start=async()=>{
30
30
  if(started)throw new Error('Teams server already started'); mkdirSync(dataDir,{recursive:true,mode:0o700});chmodSync(dataDir,0o700);
31
31
  const lock=join(dataDir,'server.lock'); const acquire=()=>{lockFd=openSync(lock,'wx',0o600);writeFileSync(lockFd,JSON.stringify({pid:process.pid,started_at:clock.nowMs()}));};try{acquire();}catch(e){let recover=false;try{const owner=JSON.parse(readFileSync(lock,'utf8'));if(Number.isInteger(owner.pid)&&owner.pid>1){try{process.kill(owner.pid,0);}catch(error){recover=error.code==='ESRCH';}}}catch{}if(!recover)throw new Error(`Teams data directory is already owned: ${dataDir}`,{cause:e});unlinkSync(lock);acquire();}
32
- try { const dbPath=join(dataDir,'teams.sqlite'); store=new TeamsStore(dbPath); api.attach({store});
32
+ try { const dbPath=join(dataDir,'teams.sqlite'); store=new TeamsStore(dbPath,{storageLimitBytes:Number(process.env.TEAMS_STORAGE_LIMIT_BYTES)||10*1024**3,storageMinFreeBytes:Number(process.env.TEAMS_STORAGE_MIN_FREE_BYTES)||0}); api.attach({store}); api.storageUsage=()=>store.call('storageUsage');
33
33
  const existing=await store.one("SELECT value FROM server_meta WHERE key='server_id'");
34
34
  const credentialPath=join(dataDir,'bootstrap-credential.json'),pendingPath=join(dataDir,'bootstrap-credential.pending.json');
35
35
  let serverId;
@@ -43,10 +43,23 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
43
43
  if(!existsSync(credentialPath)&&existsSync(pendingPath)){const pending=JSON.parse(readFileSync(pendingPath,'utf8'));if(pending.server_id!==serverId)throw new Error('bootstrap credential recovery does not match server identity');renameSync(pendingPath,credentialPath);chmodSync(credentialPath,0o600);}
44
44
  }
45
45
  api.attach({serverId});
46
- await maintain(api);maintenanceTimer=setInterval(()=>{void maintain(api).catch(()=>{});},60000);maintenanceTimer.unref?.();
46
+ const maintenance=async()=>{
47
+ let next=60000;
48
+ try{
49
+ const result=await maintain(api);next=result?.backlog?3000:60000;
50
+ if(!result?.backlog){const reclaimed=await store.call('reclaimStorage');if(reclaimed.after.allocated<reclaimed.before.allocated)console.log(`Teams storage reclaimed: ${JSON.stringify(reclaimed)}`);}
51
+ const usage=await api.storageUsage();
52
+ if(usage.used>=usage.limit*0.8||usage.available_bytes<usage.min_free_bytes+usage.limit*0.2)console.warn(`Teams storage pressure: ${JSON.stringify(usage)}; native admission stops at90% or the configured disk reserve`);
53
+ }catch(error){console.error(`Teams maintenance failed: ${error.message}`);next=3000;}
54
+ if(started){maintenanceTimer=setTimeout(()=>{maintenanceWork=maintenance();},next);maintenanceTimer.unref?.();}
55
+ return next;
56
+ };
57
+ const delay=await maintenance();
58
+ // The initial pass precedes listen; subsequent passes never overlap.
59
+ maintenanceTimer=setTimeout(()=>{maintenanceWork=maintenance();},delay);maintenanceTimer.unref?.();
47
60
  chmodSync(dbPath,0o600); http=createHttpServer(createHttpHandler(api));upgrade=createMachineUpgradeHandler(api,{authenticate});http.on('upgrade',upgrade); await new Promise((resolve,reject)=>{http.once('error',reject);http.listen(listen.port??0,listen.host??'127.0.0.1',resolve);});started=true;const addr=http.address();api.attach({localOrigin:`http://${addr.address.includes(':')?`[${addr.address}]`:addr.address}:${addr.port}`});return {address:`${addr.address}:${addr.port}`,server_id:serverId};
48
61
  } catch(e){if(maintenanceTimer){clearInterval(maintenanceTimer);maintenanceTimer=undefined;}await store?.close().catch(()=>{});store=null;api.attach({store:null});if(lockFd!==undefined){closeSync(lockFd);lockFd=undefined;}try{unlinkSync(lock);}catch{}throw e;}
49
62
  };
50
- api.close=async()=>{if(!started&&lockFd===undefined)return;started=false;if(maintenanceTimer){clearInterval(maintenanceTimer);maintenanceTimer=undefined;}events.close();closeAttachmentRelays(api,'access_revoked');upgrade?.close();await new Promise(resolve=>http?.close(()=>resolve()));await store?.close();store=null;if(lockFd!==undefined){closeSync(lockFd);lockFd=undefined;}try{unlinkSync(join(dataDir,'server.lock'));}catch{}api.attach({store:null});};
63
+ api.close=async()=>{if(!started&&lockFd===undefined)return;started=false;if(maintenanceTimer){clearInterval(maintenanceTimer);maintenanceTimer=undefined;}events.close();closeAttachmentRelays(api,'access_revoked');upgrade?.close();await new Promise(resolve=>http?.close(()=>resolve()));await maintenanceWork;await store?.close();store=null;if(lockFd!==undefined){closeSync(lockFd);lockFd=undefined;}try{unlinkSync(join(dataDir,'server.lock'));}catch{}api.attach({store:null});};
51
64
  return api;
52
65
  }
@@ -32,5 +32,5 @@ export async function storageReport({ api, res, auth, url, requestId }) {
32
32
  (SELECT COUNT(*) FROM artifact_blobs WHERE team_id=?) shared_chunks,
33
33
  (SELECT COUNT(*) FROM artifact_chunk_refs WHERE team_id=?) chunk_references,
34
34
  (SELECT COALESCE(SUM(length(bytes)),0) FROM artifact_chunks WHERE team_id=?) legacy_bytes`, Array(4).fill(auth.team_id));
35
- send(res, 200, result(api, auth, { items: rows, next: rows.length === 100 ? rows.at(-1).id : null, usage: api.storageUsage?.() ?? null, chunks }), requestId);
35
+ send(res, 200, result(api, auth, { items: rows, next: rows.length === 100 ? rows.at(-1).id : null, usage: await api.storageUsage?.() ?? null, chunks }), requestId);
36
36
  }
@@ -1,5 +1,6 @@
1
1
  import { parentPort, workerData, isMainThread, Worker } from 'node:worker_threads';
2
2
  import { DatabaseSync } from 'node:sqlite';
3
+ import { statfsSync,statSync } from 'node:fs';
3
4
  import { createStoreHandlers, migrate, StoreClient } from './store-core.mjs';
4
5
 
5
6
  // The Node host's store: node:sqlite in a worker thread, so a long statement never
@@ -12,12 +13,27 @@ if (!isMainThread) {
12
13
  const run = (sql, p=[]) => db.prepare(sql).run(...p);
13
14
  const tx = fn => { db.exec('BEGIN IMMEDIATE'); try { const out=fn(); db.exec('COMMIT'); return out; } catch(e) { db.exec('ROLLBACK'); throw e; } };
14
15
  migrate({ exec: (sql) => db.exec(sql), tx, version: () => db.prepare('PRAGMA user_version').get().user_version, setVersion: (n) => db.exec(`PRAGMA user_version=${n}`) });
15
- const handlers = { ...createStoreHandlers({ all, get, run, tx }), close: () => { db.close(); return true; } };
16
+ const usage=()=>{
17
+ const size=get('PRAGMA page_size').page_size,pages=get('PRAGMA page_count').page_count,free=get('PRAGMA freelist_count').freelist_count;
18
+ const disk=statfsSync(workerData.dbPath);let wal=0;try{wal=statSync(`${workerData.dbPath}-wal`).size;}catch{}
19
+ return {allocated:pages*size,free_bytes:free*size,used:(pages-free)*size,used_is_upper_bound:false,wal_bytes:wal,available_bytes:disk.bavail*disk.bsize,limit:workerData.storageLimitBytes,min_free_bytes:workerData.storageMinFreeBytes};
20
+ };
21
+ const reclaim=()=>{
22
+ db.exec('PRAGMA wal_checkpoint(PASSIVE)');
23
+ const before=usage();
24
+ // Legacy databases have auto_vacuum=NONE. Compact through the owning store
25
+ // after retention drains, with room for SQLite's temporary live-page copy.
26
+ if(before.free_bytes>=512*1024*1024&&before.free_bytes>before.allocated/2&&before.available_bytes>before.used+64*1024*1024){
27
+ db.exec('VACUUM');db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
28
+ }
29
+ return {before,after:usage()};
30
+ };
31
+ const handlers = { ...createStoreHandlers({ all, get, run, tx }), storageUsage:usage, reclaimStorage:reclaim, close: () => { db.close(); return true; } };
16
32
  parentPort.on('message', ({id,op,args}) => { try { parentPort.postMessage({id,result:handlers[op](args)}); } catch(error) { parentPort.postMessage({id,error:{message:error.message,code:error.code,status:error.status,stack:error.stack}}); } });
17
33
  }
18
34
 
19
35
  export class TeamsStore extends StoreClient {
20
- constructor(dbPath) { super(); this.worker=new Worker(new URL(import.meta.url),{workerData:{dbPath}}); this.next=1; this.pending=new Map(); this.worker.on('message',m=>{const p=this.pending.get(m.id); if(!p)return; this.pending.delete(m.id); m.error?p.reject(Object.assign(new Error(m.error.message),m.error)):p.resolve(m.result);}); this.worker.on('error',e=>{for(const p of this.pending.values())p.reject(e);this.pending.clear();}); }
36
+ constructor(dbPath,{storageLimitBytes=10*1024**3,storageMinFreeBytes=0}={}) { super(); this.worker=new Worker(new URL(import.meta.url),{workerData:{dbPath,storageLimitBytes,storageMinFreeBytes}}); this.next=1; this.pending=new Map(); this.worker.on('message',m=>{const p=this.pending.get(m.id); if(!p)return; this.pending.delete(m.id); m.error?p.reject(Object.assign(new Error(m.error.message),m.error)):p.resolve(m.result);}); this.worker.on('error',e=>{for(const p of this.pending.values())p.reject(e);this.pending.clear();}); }
21
37
  call(op,args={}) { return new Promise((resolve,reject)=>{const id=this.next++;this.pending.set(id,{resolve,reject});this.worker.postMessage({id,op,args});}); }
22
38
  async close() { await this.call('close'); await this.worker.terminate(); }
23
39
  }